quizapp
54.2%
Statements
6424/11857
cmd
0.8%
8/1006
internal
59.1%
6416/10851
quizapp cmd
0.8%
Statements
8/1006
adm
0.0%
0/182
cli-worker
5.0%
8/161
reset-db
0.0%
0/83
server
0.0%
0/81
setup-test-db
0.0%
0/358
worker
0.0%
0/141
quizapp cmd adm
0.0%
Statements
0/182
commands
0.0%
0/136
main.go
0.0%
0/46
quizapp cmd adm main.go
0.0%
Statements
0/136
db.go
0.0%
0/43
user.go
0.0%
0/77
utils.go
0.0%
0/16
quizapp cmd adm commands db.go
0.0%
Statements
0/43
1
// Package commands provides CLI commands for the admin tool
2
package commands
3

4
import (
5
    "context"
6
    "database/sql"
7
    "os"
8

9
    "quizapp/internal/observability"
10
    "quizapp/internal/services"
11
    contextutils "quizapp/internal/utils"
12

13
    "github.com/spf13/cobra"
14
)
15

16
// DatabaseCommands returns the database management commands
17
func DatabaseCommands(userService *services.UserService, logger *observability.Logger, db *sql.DB) *cobra.Command {
18
    dbCmd := &cobra.Command{
19
        Use:   "db",
20
        Short: "Database management commands",
21
        Long: `Database management commands for the quiz application.
22

23
Available commands:
24
  stats     - Show database statistics
25
  cleanup   - Run database cleanup operations`,
26
    }
27

28
    // Add subcommands
29
    dbCmd.AddCommand(statsCmd(userService, logger, db))
30
    dbCmd.AddCommand(cleanupCmd(logger, db))
31

32
    return dbCmd
33
}
34

35
// statsCmd returns the stats command
36
func statsCmd(userService *services.UserService, logger *observability.Logger, db *sql.DB) *cobra.Command {
37
    return &cobra.Command{
38
        Use:   "stats",
39
        Short: "Show database statistics",
40
        Long:  `Show database statistics including user counts and other metrics.`,
41
        RunE:  runStats(userService, logger, db),
42
    }
43
}
44

45
// cleanupCmd returns the cleanup command
46
func cleanupCmd(logger *observability.Logger, db *sql.DB) *cobra.Command {
47
    var statsOnly bool
48

49
    cmd := &cobra.Command{
50
        Use:   "cleanup",
51
        Short: "Run database cleanup operations",
52
        Long: `Run database cleanup operations to remove old data.
53

54
This command will:
55
- Remove questions with legacy question types
56
- Remove orphaned user responses
57

58
Use --stats flag to see what would be cleaned up without actually performing the cleanup.`,
59
        RunE: runCleanup(logger, &statsOnly, db),
60
    }
61

62
    // Add flags
63
    cmd.Flags().BoolVar(&statsOnly, "stats", false, "Only show cleanup statistics, don't perform cleanup")
64

65
    return cmd
66
}
67

68
// runStats returns a function that shows database statistics
69
func runStats(userService *services.UserService, logger *observability.Logger, db *sql.DB) func(cmd *cobra.Command, args []string) error {
70
    return func(_ *cobra.Command, _ []string) error {
71
        ctx := context.Background()
72

73
        // Log diagnostic information
74
        logger.Info(ctx, "Diagnostic info", map[string]interface{}{"config_file": os.Getenv("QUIZ_CONFIG_FILE"), "database": getDatabaseInfo(db)})
75

76
        logger.Info(ctx, "Showing database statistics", map[string]interface{}{})
77

78
        // Get user statistics
79
        users, err := userService.GetAllUsers(ctx)
80
        if err != nil {
81
            logger.Error(ctx, "Failed to get user statistics", err, map[string]interface{}{})
82
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to get user statistics: %w", err)
83
        }
84

85
        logger.Info(ctx, "Database statistics", map[string]interface{}{"total_users": len(users), "database": "PostgreSQL", "status": "Connected"})
86

87
        return nil
88
    }
89
}
90

91
// runCleanup returns a function that runs database cleanup
92
func runCleanup(logger *observability.Logger, statsOnly *bool, db *sql.DB) func(cmd *cobra.Command, args []string) error {
93
    return func(_ *cobra.Command, _ []string) error {
94
        ctx := context.Background()
95

96
        // Log diagnostic information
97
        logger.Info(ctx, "Diagnostic info", map[string]interface{}{"config_file": os.Getenv("QUIZ_CONFIG_FILE"), "database": getDatabaseInfo(db)})
98

99
        logger.Info(ctx, "Running database cleanup", map[string]interface{}{"stats_only": *statsOnly})
100

101
        // Use the database connection passed as parameter
102
        if db == nil {
103
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "database connection not available")
104
        }
105

106
        // Initialize cleanup service
107
        cleanupService := services.NewCleanupServiceWithLogger(db, logger)
108

109
        if *statsOnly {
110
            // Show cleanup statistics only
111
            stats, err := cleanupService.GetCleanupStats(ctx)
112
            if err != nil {
113
                logger.Error(ctx, "Failed to get cleanup stats", err, map[string]interface{}{"stats_only": true})
114
                return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to get cleanup stats: %w", err)
115
            }
116

117
            logger.Info(ctx, "Database cleanup statistics", map[string]interface{}{"legacy_questions": stats["legacy_questions"], "orphaned_responses": stats["orphaned_responses"]})
118

119
            total := stats["legacy_questions"] + stats["orphaned_responses"]
120
            if total == 0 {
121
                logger.Info(ctx, "No cleanup needed - database is clean", map[string]interface{}{"total": total})
122
            } else {
123
                logger.Info(ctx, "Cleanup would remove items", map[string]interface{}{"total": total})
124
            }
125
            return nil
126
        }
127

128
        // Run full cleanup
129
        logger.Info(ctx, "Starting database cleanup", map[string]interface{}{"service": "cleanup"})
130

131
        if err := cleanupService.RunFullCleanup(ctx); err != nil {
132
            logger.Error(ctx, "Cleanup failed", err, map[string]interface{}{"service": "cleanup"})
133
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "cleanup failed: %w", err)
134
        }
135

136
        logger.Info(ctx, "Database cleanup completed successfully", map[string]interface{}{"service": "cleanup"})
137
        return nil
138
    }
139
}
140


			
quizapp cmd adm commands user.go
0.0%
Statements
0/77
1
package commands
2

3
import (
4
    "context"
5
    "fmt"
6
    "os"
7
    "syscall"
8

9
    "golang.org/x/term"
10

11
    "quizapp/internal/observability"
12
    "quizapp/internal/services"
13
    contextutils "quizapp/internal/utils"
14

15
    "github.com/spf13/cobra"
16
)
17

18
// UserCommands returns the user management commands
19
func UserCommands(userService *services.UserService, logger *observability.Logger, databaseURL string) *cobra.Command {
20
    userCmd := &cobra.Command{
21
        Use:   "user",
22
        Short: "User management commands",
23
        Long: `User management commands for the quiz application.
24

25
Available commands:
26
  list     - List all users
27
  reset-password - Reset password for a specific user`,
28
    }
29

30
    // Add subcommands
31
    userCmd.AddCommand(listCmd(userService, logger, databaseURL))
32
    userCmd.AddCommand(resetPasswordCmd(userService, logger))
33

34
    return userCmd
35
}
36

37
// listCmd returns the list command
38
func listCmd(userService *services.UserService, logger *observability.Logger, databaseURL string) *cobra.Command {
39
    return &cobra.Command{
40
        Use:   "list",
41
        Short: "List all users",
42
        Long:  `List all users in the database with their basic information.`,
43
        RunE:  runListUsers(userService, logger, databaseURL),
44
    }
45
}
46

47
// resetPasswordCmd returns the reset-password command
48
func resetPasswordCmd(userService *services.UserService, logger *observability.Logger) *cobra.Command {
49
    return &cobra.Command{
50
        Use:   "reset-password [username]",
51
        Short: "Reset password for a user",
52
        Long:  `Reset the password for a specific user. If username is not provided, you will be prompted for it.`,
53
        RunE:  runResetPassword(userService, logger),
54
    }
55
}
56

57
// runListUsers returns a function that lists all users
58
func runListUsers(userService *services.UserService, logger *observability.Logger, databaseURL string) func(cmd *cobra.Command, args []string) error {
59
    return func(_ *cobra.Command, _ []string) error {
60
        ctx := context.Background()
61

62
        // Show diagnostic information
63
        logger.Info(ctx, "Admin command diagnostics", map[string]interface{}{"config_file": os.Getenv("QUIZ_CONFIG_FILE"), "database_url": maskDatabaseURL(databaseURL)})
64

65
        logger.Info(ctx, "Listing all users", map[string]interface{}{})
66

67
        users, err := userService.GetAllUsers(ctx)
68
        if err != nil {
69
            logger.Error(ctx, "Failed to get users", err, map[string]interface{}{})
70
            return contextutils.WrapError(err, "failed to get users")
71
        }
72

73
        if len(users) == 0 {
74
            logger.Info(ctx, "No users found in the database", nil)
75
            return nil
76
        }
77

78
        // Print header to stdout (user-facing table)
79
        fmt.Printf("%-5s %-20s %-30s %-15s %-10s %-10s %-10s\n", "ID", "Username", "Email", "Language", "Level", "AI Enabled", "Created")
80
        fmt.Println(string(make([]byte, 120))) // Print 120 dashes
81

82
        // Print each user
83
        for _, user := range users {
84
            aiEnabled := "No"
85
            if user.AIEnabled.Valid && user.AIEnabled.Bool {
86
                aiEnabled = "Yes"
87
            }
88

89
            email := "N/A"
90
            if user.Email.Valid {
91
                email = user.Email.String
92
            }
93

94
            language := "N/A"
95
            if user.PreferredLanguage.Valid {
96
                language = user.PreferredLanguage.String
97
            }
98

99
            level := "N/A"
100
            if user.CurrentLevel.Valid {
101
                level = user.CurrentLevel.String
102
            }
103

104
            fmt.Printf("%-5d %-20s %-30s %-15s %-10s %-10s %-10s\n",
105
                user.ID,
106
                user.Username,
107
                email,
108
                language,
109
                level,
110
                aiEnabled,
111
                user.CreatedAt.Format("2006-01-02"),
112
            )
113
        }
114

115
        logger.Info(ctx, "Listed users", map[string]interface{}{"total": len(users)})
116
        return nil
117
    }
118
}
119

120
// runResetPassword returns a function that resets a user's password
121
func runResetPassword(userService *services.UserService, logger *observability.Logger) func(cmd *cobra.Command, args []string) error {
122
    return func(_ *cobra.Command, args []string) error {
123
        ctx := context.Background()
124

125
        var username string
126
        var newPassword string
127

128
        // Get username from args or prompt
129
        if len(args) > 0 {
130
            username = args[0]
131
        } else {
132
            fmt.Print("Enter username: ")
133
            if _, err := fmt.Scanln(&username); err != nil {
134
                return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to read username: %w", err)
135
            }
136
        }
137

138
        if username == "" {
139
            return contextutils.ErrorWithContextf("username is required")
140
        }
141

142
        // Prompt for password securely
143
        fmt.Print("Enter new password: ")
144
        passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
145
        if err != nil {
146
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to read password: %w", err)
147
        }
148
        newPassword = string(passwordBytes)
149
        fmt.Println() // New line after password input
150

151
        if newPassword == "" {
152
            return contextutils.ErrorWithContextf("password cannot be empty")
153
        }
154

155
        // Confirm password
156
        fmt.Print("Confirm new password: ")
157
        confirmBytes, err := term.ReadPassword(int(syscall.Stdin))
158
        if err != nil {
159
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to read password confirmation: %w", err)
160
        }
161
        confirmPassword := string(confirmBytes)
162
        fmt.Println() // New line after password input
163

164
        if newPassword != confirmPassword {
165
            return contextutils.ErrorWithContextf("passwords do not match")
166
        }
167

168
        logger.Info(ctx, "Resetting password for user", map[string]interface{}{
169
            "username": username,
170
        })
171

172
        // Get user by username
173
        user, err := userService.GetUserByUsername(ctx, username)
174
        if err != nil {
175
            logger.Error(ctx, "Failed to get user", err, map[string]interface{}{"username": username})
176
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to get user '%s': %w", username, err)
177
        }
178

179
        if user == nil {
180
            logger.Error(ctx, "User not found", nil, map[string]interface{}{"username": username})
181
            return contextutils.ErrorWithContextf("user '%s' not found", username)
182
        }
183

184
        // Update the password
185
        err = userService.UpdateUserPassword(ctx, user.ID, newPassword)
186
        if err != nil {
187
            logger.Error(ctx, "Failed to update password", err, map[string]interface{}{
188
                "username": username,
189
                "user_id":  user.ID,
190
            })
191
            return contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to update password for user '%s': %w", username, err)
192
        }
193

194
        fmt.Printf("â Password successfully reset for user '%s' (ID: %d)\n", username, user.ID)
195
        logger.Info(ctx, "Password reset successful", map[string]interface{}{
196
            "username": username,
197
            "user_id":  user.ID,
198
        })
199

200
        return nil
201
    }
202
}
203


			
quizapp cmd adm commands utils.go
0.0%
Statements
0/16
1
package commands
2

3
import (
4
    "database/sql"
5
    "fmt"
6
    "strings"
7
)
8

9
// maskDatabaseURL masks sensitive parts of the database URL for display
10
func maskDatabaseURL(url string) string {
11
    // Simple masking for display purposes
12
    if strings.Contains(url, "@") {
13
        parts := strings.Split(url, "@")
14
        if len(parts) == 2 {
15
            return "postgres://***:***@" + parts[1]
16
        }
17
    }
18
    return url
19
}
20

21
// getDatabaseInfo returns database connection information
22
func getDatabaseInfo(db *sql.DB) string {
23
    if db == nil {
24
        return "Not connected"
25
    }
26

27
    // Try to get database name
28
    var dbName string
29
    err := db.QueryRow("SELECT current_database()").Scan(&dbName)
30
    if err != nil {
31
        return "Connected (unknown database)"
32
    }
33

34
    // Try to get host information
35
    var host string
36
    err = db.QueryRow("SELECT inet_server_addr()::text").Scan(&host)
37
    if err != nil {
38
        return fmt.Sprintf("Connected to %s", dbName)
39
    }
40

41
    return fmt.Sprintf("Connected to %s on %s", dbName, host)
42
}
43


			
quizapp cmd adm main.go
0.0%
Statements
0/46
1
// Package main provides the main entry point for the quiz application admin CLI tool.
2
package main
3

4
import (
5
    "context"
6
    "fmt"
7
    "os"
8

9
    "quizapp/cmd/adm/commands"
10
    "quizapp/internal/config"
11
    "quizapp/internal/database"
12
    "quizapp/internal/observability"
13
    "quizapp/internal/services"
14

15
    "github.com/spf13/cobra"
16
)
17

18
// Global variables for shared resources
19
var (
20
    cfg         *config.Config
21
    logger      *observability.Logger
22
    userService *services.UserService
23
)
24

25
func main() {
26
    ctx := context.Background()
27

28
    // Set default config file if not already set
29
    if os.Getenv("QUIZ_CONFIG_FILE") == "" {
30
        // Try to find the config file in common locations
31
        defaultPaths := []string{
32
            "../merged.config.yaml",    // From backend/cmd/adm/
33
            "../../merged.config.yaml", // From backend/cmd/adm/ (alternative)
34
            "merged.config.yaml",       // Current directory
35
        }
36

37
        for _, path := range defaultPaths {
38
            if _, err := os.Stat(path); err == nil {
39
                if err := os.Setenv("QUIZ_CONFIG_FILE", path); err != nil {
40
                    fmt.Fprintf(os.Stderr, "Failed to set QUIZ_CONFIG_FILE environment variable: %v\n", err)
41
                    os.Exit(1)
42
                }
43
                break
44
            }
45
        }
46
    }
47

48
    // Load configuration
49
    var err error
50
    cfg, err = config.NewConfig()
51
    if err != nil {
52
        fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
53
        os.Exit(1)
54
    }
55

56
    // Override log level for admin tool
57
    cfg.Server.LogLevel = "error"
58

59
    // Disable all OpenTelemetry features for admin CLI to avoid connection errors
60
    cfg.OpenTelemetry.EnableTracing = false
61
    cfg.OpenTelemetry.EnableMetrics = false
62
    cfg.OpenTelemetry.EnableLogging = false
63

64
    // Setup observability (tracing/metrics/logging)
65
    tp, mp, loggerInstance, err := observability.SetupObservability(&cfg.OpenTelemetry, "quiz-admin")
66
    if err != nil {
67
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
68
        os.Exit(1)
69
    }
70

71
    // Store logger globally
72
    logger = loggerInstance
73

74
    // Defer cleanup
75
    defer func() {
76
        if tp != nil {
77
            if err := tp.Shutdown(context.TODO()); err != nil {
78
                logger.Warn(ctx, "Error shutting down tracer provider", map[string]interface{}{"error": err.Error(), "provider": "tracer"})
79
            }
80
        }
81
        if mp != nil {
82
            if err := mp.Shutdown(context.TODO()); err != nil {
83
                logger.Warn(ctx, "Error shutting down meter provider", map[string]interface{}{"error": err.Error(), "provider": "meter"})
84
            }
85
        }
86
    }()
87

88
    // Initialize database manager
89
    dbManager := database.NewManager(logger)
90

91
    // Initialize database connection with configuration (no migrations for admin tool)
92
    db, err := dbManager.InitDBWithoutMigrations(cfg.Database)
93
    if err != nil {
94
        logger.Error(ctx, "Failed to connect to database", err, map[string]interface{}{"db_url": cfg.Database.URL})
95
        os.Exit(1)
96
    }
97
    defer func() {
98
        if err := db.Close(); err != nil {
99
            logger.Warn(ctx, "Warning: failed to close database connection", map[string]interface{}{"error": err.Error(), "db_url": cfg.Database.URL})
100
        }
101
    }()
102

103
    // Initialize services
104
    userService = services.NewUserServiceWithLogger(db, cfg, logger)
105

106
    // Create the root command
107
    rootCmd := &cobra.Command{
108
        Use:   "adm",
109
        Short: "Quiz Application Administration Tool",
110
        Long: `Quiz Application Administration Tool
111

112
A comprehensive CLI tool for administering the quiz application.
113
Provides commands for user management, database operations, and system administration.`,
114

115
        Run: func(cmd *cobra.Command, _ []string) {
116
            // Show help if no subcommand provided
117
            if err := cmd.Help(); err != nil {
118
                fmt.Printf("Error showing help: %v\n", err)
119
            }
120
        },
121
    }
122

123
    // Add subcommands with initialized services
124
    rootCmd.AddCommand(commands.UserCommands(userService, logger, cfg.Database.URL))
125
    rootCmd.AddCommand(commands.DatabaseCommands(userService, logger, db))
126

127
    // Execute the command
128
    if err := rootCmd.Execute(); err != nil {
129
        os.Exit(1)
130
    }
131
}
132


			
quizapp cmd cli-worker
5.0%
Statements
8/161
main.go
5.0%
8/161
quizapp cmd cli-worker main.go
5.0%
Statements
8/161
1
// Package main provides a CLI tool for running the worker to generate questions for a specific user.
2
package main
3

4
import (
5
    "context"
6
    "flag"
7
    "fmt"
8
    "os"
9
    "strings"
10
    "time"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/database"
14
    "quizapp/internal/models"
15
    "quizapp/internal/observability"
16
    "quizapp/internal/services"
17
    "quizapp/internal/worker"
18
)
19

20
func main() {
21
    ctx := context.Background()
22
    // Define command line flags
23
    var (
24
        username     = flag.String("username", "", "Username to generate questions for (required)")
25
        level        = flag.String("level", "", "Override user's current level (optional)")
26
        language     = flag.String("language", "", "Override user's preferred language (optional)")
27
        questionType = flag.String("type", "vocabulary", "Question type: vocabulary, fill_blank, qa, reading_comprehension")
28
        topic        = flag.String("topic", "", "Specific topic for questions (optional)")
29
        count        = flag.Int("count", 5, "Number of questions to generate")
30
        aiProvider   = flag.String("ai-provider", "", "Override AI provider (optional)")
31
        aiModel      = flag.String("ai-model", "", "Override AI model (optional)")
32
        aiAPIKey     = flag.String("ai-api-key", "", "Override AI API key (optional)")
33
        help         = flag.Bool("help", false, "Show help message")
34
    )
35

36
    flag.Parse()
37

38
    if *help {
39
        printUsage(nil)
40
        return
41
    }
42

43
    if *username == "" {
44
        fmt.Fprintln(os.Stderr, "Error: --username flag is required")
45
        os.Exit(1)
46
    }
47

48
    // Load configuration
49
    cfg, err := config.NewConfig()
50
    if err != nil {
51
        fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
52
        os.Exit(1)
53
    }
54

55
    // Setup observability (tracing/metrics/logging)
56
    tp, mp, logger, err := observability.SetupObservability(&cfg.OpenTelemetry, "quiz-cli-worker")
57
    if err != nil {
58
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
59
        os.Exit(1)
60
    }
61
    defer func() {
62
        if tp != nil {
63
            if err := tp.Shutdown(context.TODO()); err != nil {
64
                logger.Warn(ctx, "Error shutting down tracer provider", map[string]interface{}{"error": err.Error()})
65
            }
66
        }
67
        if mp != nil {
68
            if err := mp.Shutdown(context.TODO()); err != nil {
69
                logger.Warn(ctx, "Error shutting down meter provider", map[string]interface{}{"error": err.Error()})
70
            }
71
        }
72
    }()
73

74
    logger.Info(ctx, "Starting quiz CLI worker", map[string]interface{}{
75
        "username":      *username,
76
        "question_type": *questionType,
77
        "count":         *count,
78
    })
79

80
    // Validate question type
81
    validTypes := map[string]models.QuestionType{
82
        "vocabulary":            models.Vocabulary,
83
        "fill_blank":            models.FillInBlank,
84
        "qa":                    models.QuestionAnswer,
85
        "reading_comprehension": models.ReadingComprehension,
86
    }
87

88
    qType, valid := validTypes[strings.ToLower(*questionType)]
89
    if !valid {
90
        logger.Error(ctx, "Invalid question type", nil, map[string]interface{}{"question_type": *questionType})
91
        fmt.Fprintf(os.Stderr, "Error: Invalid question type '%s'\n", *questionType)
92
        os.Exit(1)
93
    }
94

95
    // Validate level if provided
96
    if *level != "" {
97
        if !isValidLevel(*level, cfg.GetAllLevels()) {
98
            logger.Error(ctx, "Invalid level", nil, map[string]interface{}{"level": *level})
99
            fmt.Fprintf(os.Stderr, "Error: Invalid level '%s'\n", *level)
100
            os.Exit(1)
101
        }
102
    }
103

104
    // Validate language if provided (use dynamic list from config)
105
    validLanguages := cfg.GetLanguages()
106
    if *language != "" {
107
        if !isValidLanguage(*language, validLanguages) {
108
            logger.Error(ctx, "Invalid language", nil, map[string]interface{}{"language": *language})
109
            fmt.Fprintf(os.Stderr, "Error: Invalid language '%s'\n", *language)
110
            os.Exit(1)
111
        }
112
    }
113

114
    // Initialize database manager with logger
115
    dbManager := database.NewManager(logger)
116

117
    // Initialize database connection with configuration
118
    db, err := dbManager.InitDBWithoutMigrations(cfg.Database)
119
    if err != nil {
120
        logger.Error(ctx, "Failed to connect to database", err, map[string]interface{}{"db_url": cfg.Database.URL})
121
        fmt.Fprintf(os.Stderr, "Failed to connect to database: %v\n", err)
122
        os.Exit(1)
123
    }
124
    defer func() {
125
        if err := db.Close(); err != nil {
126
            logger.Warn(ctx, "Warning: failed to close database connection", map[string]interface{}{"error": err.Error(), "db_url": cfg.Database.URL})
127
        }
128
    }()
129

130
    // Initialize services
131
    userService := services.NewUserServiceWithLogger(db, cfg, logger)
132
    learningService := services.NewLearningServiceWithLogger(db, cfg, logger)
133
    // Create question service
134
    questionService := services.NewQuestionServiceWithLogger(db, learningService, cfg, logger)
135
    aiService := services.NewAIService(cfg, logger)
136
    workerService := services.NewWorkerServiceWithLogger(db, logger)
137

138
    // Get user by username
139
    user, err := userService.GetUserByUsername(ctx, *username)
140
    if err != nil {
141
        logger.Error(ctx, "Failed to get user", err)
142
        fmt.Fprintf(os.Stderr, "Failed to get user: %v\n", err)
143
        os.Exit(1)
144
    }
145
    if user == nil {
146
        logger.Error(ctx, "User not found", nil, map[string]interface{}{"username": *username})
147
        fmt.Fprintf(os.Stderr, "User not found: %s\n", *username)
148
        os.Exit(1)
149
        return
150
    }
151
    logger.Info(ctx, "Found user", map[string]interface{}{"username": user.Username, "user_id": user.ID})
152

153
    // Apply AI overrides if provided
154
    if *aiProvider != "" {
155
        user.AIProvider.String = *aiProvider
156
        user.AIProvider.Valid = true
157
        user.AIEnabled.Bool = true
158
        user.AIEnabled.Valid = true
159
    }
160
    if *aiModel != "" {
161
        user.AIModel.String = *aiModel
162
        user.AIModel.Valid = true
163
    }
164
    if *aiAPIKey != "" {
165
        // Set AI provider and API key if provided
166
        if *aiProvider != "" && *aiAPIKey != "" {
167
            if err := userService.SetUserAPIKey(ctx, user.ID, *aiProvider, *aiAPIKey); err != nil {
168
                logger.Error(ctx, "Failed to set API key", err)
169
                fmt.Fprintf(os.Stderr, "Failed to set API key: %v\n", err)
170
                os.Exit(1)
171
            }
172
        } else if *aiAPIKey != "" {
173
            // If only API key is provided, use the user's current AI provider
174
            if err := userService.SetUserAPIKey(ctx, user.ID, user.AIProvider.String, *aiAPIKey); err != nil {
175
                logger.Error(ctx, "Failed to set API key", err)
176
                fmt.Fprintf(os.Stderr, "Failed to set API key: %v\n", err)
177
                os.Exit(1)
178
            }
179
        }
180
    }
181

182
    // Check if user has AI enabled (after potential overrides)
183
    if !user.AIEnabled.Valid || !user.AIEnabled.Bool {
184
        logger.Warn(ctx, "User does not have AI enabled", map[string]interface{}{"username": user.Username, "user_id": user.ID})
185
        logger.Info(ctx, "You may want to enable AI for this user first or use --ai-provider flag")
186
    }
187

188
    // Determine language and level to use
189
    languageToUse := user.PreferredLanguage.String
190
    if *language != "" {
191
        languageToUse = *language
192
    }
193

194
    levelToUse := user.CurrentLevel.String
195
    if *level != "" {
196
        levelToUse = *level
197
    }
198

199
    // Validate that we have required settings
200
    if languageToUse == "" {
201
        logger.Error(ctx, "No language specified", nil, map[string]interface{}{"username": user.Username, "user_id": user.ID})
202
        fmt.Fprintln(os.Stderr, "Error: No language specified. User has no preferred language and --language flag not provided")
203
        os.Exit(1)
204
    }
205
    if levelToUse == "" {
206
        logger.Error(ctx, "No level specified", nil, map[string]interface{}{"username": user.Username, "user_id": user.ID})
207
        fmt.Fprintln(os.Stderr, "Error: No level specified. User has no current level and --level flag not provided")
208
        os.Exit(1)
209
    }
210

211
    // Print configuration
212
    fmt.Printf("=== CLI Worker Configuration ===\n")
213
    fmt.Printf("User: %s (ID: %d)\n", user.Username, user.ID)
214
    fmt.Printf("Language: %s\n", languageToUse)
215
    fmt.Printf("Level: %s\n", levelToUse)
216
    fmt.Printf("Question Type: %s\n", qType)
217
    fmt.Printf("Count: %d\n", *count)
218
    if *topic != "" {
219
        fmt.Printf("Topic: %s\n", *topic)
220
    }
221
    if user.AIProvider.Valid && user.AIProvider.String != "" {
222
        fmt.Printf("AI Provider: %s\n", user.AIProvider.String)
223
    }
224
    if user.AIModel.Valid && user.AIModel.String != "" {
225
        fmt.Printf("AI Model: %s\n", user.AIModel.String)
226
    }
227
    fmt.Printf("===============================\n\n")
228

229
    // Create email service
230
    emailService := services.CreateEmailService(cfg, logger)
231
    // Create daily question service
232
    dailyQuestionService := services.NewDailyQuestionService(db, logger, questionService, learningService)
233

234
    // Create a minimal worker instance for question generation
235
    workerInstance := worker.NewWorker(userService, questionService, aiService, learningService, workerService, dailyQuestionService, emailService, nil, "cli", cfg, logger)
236

237
    // Create context with timeout
238
    ctx, cancel := context.WithTimeout(ctx, config.CLIWorkerTimeout)
239
    defer cancel()
240

241
    // Log CLI worker start with structured logging
242
    logger.Info(ctx, "CLI worker starting question generation", map[string]interface{}{
243
        "user_id":       user.ID,
244
        "username":      user.Username,
245
        "question_type": qType,
246
        "count":         *count,
247
        "language":      languageToUse,
248
        "level":         levelToUse,
249
    })
250

251
    // Generate questions
252
    fmt.Printf("Starting question generation...\n")
253
    startTime := time.Now()
254

255
    result, err := workerInstance.GenerateQuestionsForUser(ctx, user, languageToUse, levelToUse, qType, *count, *topic)
256

257
    duration := time.Since(startTime)
258

259
    if err != nil {
260
        fmt.Printf("\nâ Question generation failed after %v\n", duration)
261
        fmt.Printf("Error: %v\n", err)
262
        os.Exit(1)
263
    }
264

265
    fmt.Printf("\nâ Question generation completed successfully in %v\n", duration)
266
    fmt.Printf("Result: %s\n", result)
267
}
268

269
8x
func isValidLevel(level string, validLevels []string) bool {
270
8x
    for _, validLevel := range validLevels {
271
33x
        if strings.EqualFold(level, validLevel) {
272
6x
            return true
273
6x
        }
274
    }
275
2x
    return false
276
}
277

278
6x
func isValidLanguage(language string, validLanguages []string) bool {
279
6x
    for _, validLang := range validLanguages {
280
18x
        if strings.EqualFold(language, validLang) {
281
4x
            return true
282
4x
        }
283
    }
284
2x
    return false
285
}
286

287
func printUsage(cfg *config.Config) {
288
    if cfg == nil {
289
        fmt.Fprintf(os.Stderr, "Error: Configuration is missing or invalid.\n")
290
        return
291
    }
292
    fmt.Printf("Usage: cli-worker [flags]\n")
293
    fmt.Printf("Flags:\n")
294
    fmt.Printf("  -language string\tLanguage to generate questions for\n")
295
    fmt.Printf("  -level string\tLevel to generate questions for\n")
296
    fmt.Printf("  -type string\tQuestion type (vocabulary, fill_in_blank, qa, reading_comprehension)\n")
297
    fmt.Printf("  -count int\tNumber of questions to generate (default 1)\n")
298
    fmt.Printf("  -topic string\tTopic for question generation\n")
299
    fmt.Printf("  -provider string\tAI provider to use\n")
300
    fmt.Printf("  -model string\tAI model to use\n")
301
    fmt.Printf("  -help\tShow this help message\n\n")
302

303
    fmt.Printf("Valid levels: %s\n", strings.Join(cfg.GetAllLevels(), ", "))
304
    fmt.Printf("Valid languages: %s\n", strings.Join(cfg.GetLanguages(), ", "))
305
    if cfg.Providers != nil {
306
        providerNames := make([]string, 0, len(cfg.Providers))
307
        for _, p := range cfg.Providers {
308
            providerNames = append(providerNames, p.Code)
309
        }
310
        fmt.Printf("Valid providers: %s\n", strings.Join(providerNames, ", "))
311
    } else {
312
        fmt.Printf("Valid providers: \n")
313
    }
314
}
315


			
quizapp cmd reset-db
0.0%
Statements
0/83
main.go
0.0%
0/83
quizapp cmd reset-db main.go
0.0%
Statements
0/83
1
// Package main provides a small CLI utility to reset the application's
2
// database to a clean state. It is intended for local development and
3
// testing only and will permanently delete all data when run.
4
package main
5

6
import (
7
    "bufio"
8
    "context"
9
    "fmt"
10
    "os"
11
    "strings"
12

13
    "quizapp/internal/config"
14
    "quizapp/internal/database"
15
    "quizapp/internal/observability"
16
    "quizapp/internal/services"
17
)
18

19
// fatalIfErr logs the error with context and exits
20
func fatalIfErr(ctx context.Context, logger *observability.Logger, msg string, err error, fields map[string]interface{}) {
21
    logger.Error(ctx, msg, err, fields)
22
    os.Exit(1)
23
}
24

25
func main() {
26
    ctx := context.Background()
27

28
    // Load configuration first
29
    cfg, err := config.NewConfig()
30
    if err != nil {
31
        fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
32
        os.Exit(1)
33
    }
34

35
    // Setup observability (tracing/metrics/logging)
36
    tp, mp, logger, err := observability.SetupObservability(&cfg.OpenTelemetry, "reset-db")
37
    if err != nil {
38
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
39
        os.Exit(1)
40
    }
41
    defer func() {
42
        if tp != nil {
43
            if err := tp.Shutdown(context.TODO()); err != nil {
44
                logger.Warn(ctx, "Error shutting down tracer provider", map[string]interface{}{"error": err.Error(), "provider": "tracer"})
45
            }
46
        }
47
        if mp != nil {
48
            if err := mp.Shutdown(context.TODO()); err != nil {
49
                logger.Warn(ctx, "Error shutting down meter provider", map[string]interface{}{"error": err.Error(), "provider": "meter"})
50
            }
51
        }
52
    }()
53

54
    fmt.Println("âï  DATABASE RESET UTILITY âï")
55
    fmt.Println("=============================")
56
    fmt.Println("This will PERMANENTLY DELETE ALL DATA in the database!")
57
    fmt.Println("This includes:")
58
    fmt.Println("- All users (including admin)")
59
    fmt.Println("- All questions")
60
    fmt.Println("- All user responses")
61
    fmt.Println("- All performance metrics")
62
    fmt.Println("")
63

64
    logger.Info(ctx, "Attempting to reset the database", map[string]interface{}{"service": "reset-db"})
65

66
    if cfg.Database.URL == "" {
67
        fatalIfErr(ctx, logger, "Database URL is empty", nil, map[string]interface{}{"error": "Database URL is empty. Cannot proceed with reset."})
68
    }
69

70
    // Print database info
71
    fmt.Println("ð Database Information:")
72
    fmt.Printf("URL: %s\n", maskDatabaseURL(cfg.Database.URL))
73
    fmt.Println("")
74

75
    // Confirm with user
76
    if !confirmReset() {
77
        fmt.Println("Reset cancelled.")
78
        return
79
    }
80

81
    // Initialize database manager with logger
82
    dbManager := database.NewManager(logger)
83

84
    // Initialize database connection with configuration
85
    db, err := dbManager.InitDBWithConfig(cfg.Database)
86
    if err != nil {
87
        fatalIfErr(ctx, logger, "Failed to connect to database", err, map[string]interface{}{"db_url": cfg.Database.URL})
88
    }
89
    defer func() {
90
        if err := db.Close(); err != nil {
91
            logger.Warn(ctx, "Warning: failed to close database connection", map[string]interface{}{"error": err.Error(), "db_url": cfg.Database.URL})
92
        }
93
    }()
94

95
    // Initialize services
96
    userService := services.NewUserServiceWithLogger(db, cfg, logger)
97

98
    // Drop all tables
99
    fmt.Println("ðï  Dropping all tables...")
100
    logger.Info(ctx, "Dropping all tables", map[string]interface{}{"db_url": cfg.Database.URL, "service": "reset-db"})
101

102
    // For now, we'll just run migrations which will recreate the schema
103
    // In a real implementation, you might want to add a DropAllTables method to the database manager
104

105
    // Run migrations
106
    fmt.Println("ð Running database migrations...")
107
    logger.Info(ctx, "Running database migrations", map[string]interface{}{"db_url": cfg.Database.URL, "service": "reset-db"})
108

109
    if err := dbManager.RunMigrations(db); err != nil {
110
        fatalIfErr(ctx, logger, "Failed to run migrations", err, map[string]interface{}{"db_url": cfg.Database.URL})
111
    }
112

113
    fmt.Println("â Database migrations completed successfully!")
114
    logger.Info(ctx, "Database migrations completed successfully", map[string]interface{}{"db_url": cfg.Database.URL, "service": "reset-db"})
115

116
    // Recreate admin user immediately
117
    fmt.Printf("Recreating admin user '%s'...\n", cfg.Server.AdminUsername)
118
    logger.Info(ctx, "Recreating admin user", map[string]interface{}{"username": cfg.Server.AdminUsername, "service": "reset-db"})
119
    // Ensure admin user exists
120
    if err := userService.EnsureAdminUserExists(ctx, cfg.Server.AdminUsername, cfg.Server.AdminPassword); err != nil {
121
        fatalIfErr(ctx, logger, "Failed to ensure admin user exists", err, map[string]interface{}{"admin_username": cfg.Server.AdminUsername})
122
    }
123

124
    fmt.Println("â Admin user recreated successfully!")
125
    logger.Info(ctx, "Admin user recreated successfully", map[string]interface{}{"username": cfg.Server.AdminUsername, "service": "reset-db"})
126
    fmt.Println("")
127
    // Print admin credentials
128
    fmt.Printf("\nAdmin user credentials:\n")
129
    fmt.Printf("   Username: %s\n", cfg.Server.AdminUsername)
130
    fmt.Printf("   Password: %s\n", cfg.Server.AdminPassword)
131
    fmt.Println("")
132
    fmt.Println("â Database is now ready to use!")
133
    fmt.Println("- You can now start the server or use the existing running instance")
134
    fmt.Println("- Use the credentials above to log into the application")
135
}
136

137
func confirmReset() bool {
138
    reader := bufio.NewReader(os.Stdin)
139

140
    for {
141
        fmt.Print("Are you sure you want to reset the database? (type 'yes' to confirm): ")
142
        response, err := reader.ReadString('\n')
143
        if err != nil {
144
            fmt.Println("Error reading input:", err)
145
            continue
146
        }
147

148
        response = strings.TrimSpace(strings.ToLower(response))
149

150
        switch response {
151
        case "yes":
152
            return true
153
        case "no", "":
154
            return false
155
        default:
156
            fmt.Println("Please type 'yes' to confirm or 'no' to cancel.")
157
        }
158
    }
159
}
160

161
func maskDatabaseURL(url string) string {
162
    // Simple masking for display purposes
163
    if strings.Contains(url, "@") {
164
        parts := strings.Split(url, "@")
165
        if len(parts) == 2 {
166
            return "postgres://***:***@" + parts[1]
167
        }
168
    }
169
    return url
170
}
171


			
quizapp cmd server
0.0%
Statements
0/81
main.go
0.0%
0/81
quizapp cmd server main.go
0.0%
Statements
0/81
1
// Package main provides the main entry point for the quiz application backend server.
2
// It sets up the HTTP server, database connections, middleware, and API routes.
3
package main
4

5
import (
6
    "context"
7
    "fmt"
8
    "os"
9
    "os/signal"
10
    "syscall"
11
    "time"
12

13
    "quizapp/internal/config"
14
    "quizapp/internal/di"
15
    "quizapp/internal/handlers"
16
    "quizapp/internal/observability"
17
    contextutils "quizapp/internal/utils"
18

19
    "github.com/gin-gonic/gin"
20
)
21

22
// Application encapsulates the main application logic and can be tested
23
type Application struct {
24
    container di.ServiceContainerInterface
25
    router    *gin.Engine
26
}
27

28
// NewApplication creates a new application instance
29
func NewApplication(container di.ServiceContainerInterface) (*Application, error) {
30
    // Get services from container
31
    userService, err := container.GetUserService()
32
    if err != nil {
33
        return nil, contextutils.WrapError(err, "failed to get user service")
34
    }
35

36
    questionService, err := container.GetQuestionService()
37
    if err != nil {
38
        return nil, contextutils.WrapError(err, "failed to get question service")
39
    }
40

41
    learningService, err := container.GetLearningService()
42
    if err != nil {
43
        return nil, contextutils.WrapError(err, "failed to get learning service")
44
    }
45

46
    aiService, err := container.GetAIService()
47
    if err != nil {
48
        return nil, contextutils.WrapError(err, "failed to get AI service")
49
    }
50

51
    workerService, err := container.GetWorkerService()
52
    if err != nil {
53
        return nil, contextutils.WrapError(err, "failed to get worker service")
54
    }
55

56
    dailyQuestionService, err := container.GetDailyQuestionService()
57
    if err != nil {
58
        return nil, contextutils.WrapError(err, "failed to get daily question service")
59
    }
60

61
    oauthService, err := container.GetOAuthService()
62
    if err != nil {
63
        return nil, contextutils.WrapError(err, "failed to get OAuth service")
64
    }
65

66
    generationHintService, err := container.GetGenerationHintService()
67
    if err != nil {
68
        return nil, contextutils.WrapError(err, "failed to get generation hint service")
69
    }
70

71
    // Use the router factory
72
    router := handlers.NewRouter(
73
        container.GetConfig(),
74
        userService,
75
        questionService,
76
        learningService,
77
        aiService,
78
        workerService,
79
        dailyQuestionService,
80
        oauthService,
81
        generationHintService,
82
        container.GetLogger(),
83
    )
84

85
    return &Application{
86
        container: container,
87
        router:    router,
88
    }, nil
89
}
90

91
// Run starts the application and returns an error if it fails to start
92
func (a *Application) Run(ctx context.Context, port string) error {
93
    // Start server in a goroutine
94
    serverErr := make(chan error, 1)
95
    go func() {
96
        if err := a.router.Run(":" + port); err != nil {
97
            serverErr <- err
98
        }
99
    }()
100

101
    // Wait for shutdown signal or server error
102
    select {
103
    case <-ctx.Done():
104
        return nil // Context cancelled, graceful shutdown
105
    case err := <-serverErr:
106
        return contextutils.WrapError(err, "server failed")
107
    }
108
}
109

110
// Shutdown gracefully shuts down the application
111
func (a *Application) Shutdown(ctx context.Context) error {
112
    return a.container.Shutdown(ctx)
113
}
114

115
func main() {
116
    ctx, cancel := context.WithCancel(context.Background())
117
    defer cancel()
118

119
    // Setup graceful shutdown
120
    shutdownCh := make(chan os.Signal, 1)
121
    signal.Notify(shutdownCh, syscall.SIGINT, syscall.SIGTERM)
122

123
    // Load configuration
124
    cfg, err := config.NewConfig()
125
    if err != nil {
126
        fmt.Fprintf(os.Stderr, "Failed to load configuration: %v\n", err)
127
        os.Exit(1)
128
    }
129

130
    // Setup observability (tracing/metrics/logging)
131
    tp, mp, logger, err := observability.SetupObservability(&cfg.OpenTelemetry, "quiz-backend")
132
    if err != nil {
133
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
134
        os.Exit(1)
135
    }
136
    defer func() {
137
        shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
138
        defer shutdownCancel()
139

140
        if tp != nil {
141
            if err := tp.Shutdown(shutdownCtx); err != nil {
142
                logger.Warn(ctx, "Error shutting down tracer provider", map[string]interface{}{"error": err.Error(), "provider": "tracer"})
143
            }
144
        }
145
        if mp != nil {
146
            if err := mp.Shutdown(shutdownCtx); err != nil {
147
                logger.Warn(ctx, "Error shutting down meter provider", map[string]interface{}{"error": err.Error(), "provider": "meter"})
148
            }
149
        }
150
    }()
151

152
    logger.Info(ctx, "Starting quiz backend service", map[string]interface{}{
153
        "port":     cfg.Server.Port,
154
        "logLevel": cfg.Server.LogLevel,
155
    })
156

157
    // Initialize dependency injection container
158
    container := di.NewServiceContainer(cfg, logger)
159

160
    // Initialize all services
161
    if err := container.Initialize(ctx); err != nil {
162
        logger.Error(ctx, "Failed to initialize services", err, nil)
163
        os.Exit(1)
164
    }
165

166
    // Ensure admin user exists
167
    if err := container.EnsureAdminUser(ctx); err != nil {
168
        logger.Error(ctx, "Failed to ensure admin user exists", err, map[string]interface{}{"admin_username": cfg.Server.AdminUsername})
169
        os.Exit(1)
170
    }
171

172
    // Create application instance
173
    app, err := NewApplication(container)
174
    if err != nil {
175
        logger.Error(ctx, "Failed to create application", err, nil)
176
        os.Exit(1)
177
    }
178

179
    // Start application in a goroutine
180
    appErr := make(chan error, 1)
181
    go func() {
182
        if err := app.Run(ctx, cfg.Server.Port); err != nil {
183
            appErr <- err
184
        }
185
    }()
186

187
    // Wait for shutdown signal or application error
188
    select {
189
    case <-shutdownCh:
190
        logger.Info(ctx, "Received shutdown signal, shutting down gracefully", nil)
191
    case err := <-appErr:
192
        logger.Error(ctx, "Application failed", err, nil)
193
        os.Exit(1)
194
    }
195

196
    // Graceful shutdown
197
    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
198
    defer shutdownCancel()
199

200
    // Shutdown application
201
    if err := app.Shutdown(shutdownCtx); err != nil {
202
        logger.Error(ctx, "Error during application shutdown", err, nil)
203
        os.Exit(1)
204
    }
205

206
    logger.Info(ctx, "Shutdown completed successfully", nil)
207
}
208


			
quizapp cmd setup-test-db
0.0%
Statements
0/358
main.go
0.0%
0/358
quizapp cmd setup-test-db main.go
0.0%
Statements
0/358
1
// Package main provides a utility to set up the test database with initial data.
2
package main
3

4
import (
5
    "context"
6
    "database/sql"
7
    "encoding/json"
8
    "flag"
9
    "fmt"
10
    "os"
11
    "path/filepath"
12
    "strings"
13
    "time"
14

15
    "quizapp/internal/config"
16
    "quizapp/internal/database"
17
    "quizapp/internal/models"
18
    "quizapp/internal/observability"
19
    "quizapp/internal/services"
20
    contextutils "quizapp/internal/utils"
21

22
    "go.uber.org/zap/zapcore"
23
    "gopkg.in/yaml.v3"
24
)
25

26
// TestUser represents a user in the test data files
27
type TestUser struct {
28
    Username          string   `yaml:"username"`
29
    Email             string   `yaml:"email"`
30
    Password          string   `yaml:"password"` // Special field for password creation
31
    PreferredLanguage string   `yaml:"preferred_language"`
32
    CurrentLevel      string   `yaml:"current_level"`
33
    AIProvider        string   `yaml:"ai_provider"`
34
    AIModel           string   `yaml:"ai_model"`
35
    AIAPIKey          string   `yaml:"ai_api_key"`
36
    Roles             []string `yaml:"roles"`
37
}
38

39
// TestUsers represents a collection of test users
40
type TestUsers struct {
41
    Users []TestUser `yaml:"users"`
42
}
43

44
// TestQuestions represents a collection of test questions
45
type TestQuestions struct {
46
    Questions []models.Question `yaml:"questions"`
47
}
48

49
// TestResponses represents a collection of test user responses
50
type TestResponses struct {
51
    UserResponses []struct {
52
        Username       string `yaml:"username"`
53
        QuestionIndex  int    `yaml:"question_index"`
54
        UserAnswer     string `yaml:"user_answer"`
55
        IsCorrect      bool   `yaml:"is_correct"`
56
        ResponseTimeMs int    `yaml:"response_time_ms"`
57
    } `yaml:"user_responses"`
58

59
    QuestionReports []struct {
60
        Username      string  `yaml:"username"`
61
        QuestionIndex int     `yaml:"question_index"`
62
        ReportReason  string  `yaml:"report_reason"`
63
        CreatedAt     *string `yaml:"created_at"`
64
    } `yaml:"question_reports"`
65
}
66

67
// TestAnalytics represents analytics test data
68
type TestAnalytics struct {
69
    PriorityScores []struct {
70
        Username         string  `yaml:"username"`
71
        QuestionIndex    int     `yaml:"question_index"`
72
        PriorityScore    float64 `yaml:"priority_score"`
73
        LastCalculatedAt string  `yaml:"last_calculated_at"`
74
    } `yaml:"priority_scores"`
75

76
    LearningPreferences []struct {
77
        Username             string  `yaml:"username"`
78
        FocusOnWeakAreas     bool    `yaml:"focus_on_weak_areas"`
79
        FreshQuestionRatio   float64 `yaml:"fresh_question_ratio"`
80
        WeakAreaBoost        float64 `yaml:"weak_area_boost"`
81
        KnownQuestionPenalty float64 `yaml:"known_question_penalty"`
82
        ReviewIntervalDays   int     `yaml:"review_interval_days"`
83
        DailyReminderEnabled bool    `yaml:"daily_reminder_enabled"`
84
    } `yaml:"learning_preferences"`
85

86
    PerformanceMetrics []struct {
87
        Username              string  `yaml:"username"`
88
        Topic                 string  `yaml:"topic"`
89
        Language              string  `yaml:"language"`
90
        Level                 string  `yaml:"level"`
91
        TotalAttempts         int     `yaml:"total_attempts"`
92
        CorrectAttempts       int     `yaml:"correct_attempts"`
93
        AverageResponseTimeMs float64 `yaml:"average_response_time_ms"`
94
    } `yaml:"performance_metrics"`
95

96
    UserQuestionMetadata []struct {
97
        Username        string  `yaml:"username"`
98
        QuestionIndex   int     `yaml:"question_index"`
99
        MarkedAsKnown   bool    `yaml:"marked_as_known"`
100
        MarkedAsKnownAt *string `yaml:"marked_as_known_at"`
101
    } `yaml:"user_question_metadata"`
102
}
103

104
// TestDailyAssignments represents the structure for daily question assignments in test data
105
type TestDailyAssignments struct {
106
    DailyAssignments []struct {
107
        Username           string `yaml:"username"`
108
        Date               string `yaml:"date"`
109
        QuestionIDs        []int  `yaml:"question_ids"`
110
        CompletedQuestions []int  `yaml:"completed_questions"`
111
    } `yaml:"daily_assignments"`
112
}
113

114
func resetTestDatabase(databaseURL, testDB string, logger *observability.Logger) error {
115
    ctx := context.Background()
116

117
    // Create admin connection string by replacing the database name with 'postgres'
118
    // This connects to the admin database to drop/create the test database
119
    adminConnStr := strings.Replace(databaseURL, "/"+testDB+"?", "/postgres?", 1)
120
    if !strings.Contains(adminConnStr, "/postgres?") {
121
        // Handle case where there's no query string
122
        adminConnStr = strings.Replace(databaseURL, "/"+testDB, "/postgres", 1)
123
    }
124

125
    logger.Info(ctx, "Connecting to admin database", map[string]interface{}{"connection_string": adminConnStr})
126
    adminDB, err := sql.Open("postgres", adminConnStr)
127
    if err != nil {
128
        return contextutils.WrapErrorf(contextutils.ErrDatabaseConnection, "failed to connect to postgres database for drop/create: %v", err)
129
    }
130
    defer func() {
131
        if err := adminDB.Close(); err != nil {
132
            logger.Warn(ctx, "Warning: failed to close adminDB", map[string]interface{}{"error": err.Error()})
133
        }
134
    }()
135

136
    logger.Info(ctx, "Terminating connections to test DB", map[string]interface{}{"database": testDB})
137
    _, err = adminDB.Exec(fmt.Sprintf(`
138
        SELECT pg_terminate_backend(pid)
139
        FROM pg_stat_activity
140
        WHERE datname = '%s' AND pid <> pg_backend_pid();
141
    `, testDB))
142
    if err != nil {
143
        logger.Warn(ctx, "Warning: failed to terminate connections", map[string]interface{}{"error": err.Error()})
144
    }
145

146
    logger.Info(ctx, "Dropping test database", map[string]interface{}{"database": testDB})
147
    _, err = adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE);", testDB))
148
    if err != nil {
149
        return contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to drop test database: %v", err)
150
    }
151
    logger.Info(ctx, "Successfully dropped test database", map[string]interface{}{"database": testDB})
152

153
    logger.Info(ctx, "Creating test database", map[string]interface{}{"database": testDB})
154
    _, err = adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s;", testDB))
155
    if err != nil {
156
        return contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to create test database: %v", err)
157
    }
158
    logger.Info(ctx, "Successfully created test database", map[string]interface{}{"database": testDB})
159

160
    logger.Info(ctx, "Test database reset complete")
161
    return nil
162
}
163

164
func main() {
165
    ctx := context.Background()
166

167
    // CLI flags
168
    verbose := flag.Bool("verbose", false, "enable verbose logging")
169
    flag.Parse()
170

171
    // Load configuration first
172
    cfg, err := config.NewConfig()
173
    if err != nil {
174
        fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
175
        os.Exit(1)
176
    }
177

178
    // Setup observability (tracing/metrics). Suppress logger creation here to avoid startup noise.
179
    originalLogging := cfg.OpenTelemetry.EnableLogging
180
    cfg.OpenTelemetry.EnableLogging = false
181
    tp, mp, _, err := observability.SetupObservability(&cfg.OpenTelemetry, "setup-test-db")
182
    if err != nil {
183
        fmt.Fprintf(os.Stderr, "Failed to initialize observability: %v\n", err)
184
        os.Exit(1)
185
    }
186

187
    // Create logger with level based on --verbose flag
188
    logLevel := zapcore.WarnLevel
189
    if *verbose {
190
        logLevel = zapcore.InfoLevel
191
    }
192
    // Restore config flag for logger construction (to allow OTLP exporter if enabled)
193
    cfg.OpenTelemetry.EnableLogging = originalLogging
194
    logger := observability.NewLoggerWithLevel(&cfg.OpenTelemetry, logLevel)
195
    defer func() {
196
        if tp != nil {
197
            if err := tp.Shutdown(context.TODO()); err != nil {
198
                logger.Warn(ctx, "Error shutting down tracer provider", map[string]interface{}{"error": err.Error()})
199
            }
200
        }
201
        if mp != nil {
202
            if err := mp.Shutdown(context.TODO()); err != nil {
203
                logger.Warn(ctx, "Error shutting down meter provider", map[string]interface{}{"error": err.Error()})
204
            }
205
        }
206
    }()
207

208
    // Get DB connection info from env or use defaults
209
    dbUser := "quiz_user"
210
    dbPassword := "quiz_password"
211
    dbHost := "localhost"
212
    dbPort := "5433"
213
    testDB := "quiz_test_db"
214

215
    // Allow override from DATABASE_URL
216
    databaseURL := os.Getenv("DATABASE_URL")
217
    if databaseURL == "" {
218
        databaseURL = fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", dbUser, dbPassword, dbHost, dbPort, testDB)
219
    }
220

221
    // Debug: Print the DATABASE_URL we're using
222
    logger.Info(ctx, "DATABASE_URL from environment", map[string]interface{}{"database_url": os.Getenv("DATABASE_URL")})
223
    logger.Info(ctx, "Using database URL", map[string]interface{}{"database_url": databaseURL})
224

225
    // --- Drop and recreate the test database ---
226
    if err := resetTestDatabase(databaseURL, testDB, logger); err != nil {
227
        logger.Error(ctx, "Failed to reset test database", err)
228
        os.Exit(1)
229
    }
230

231
    // Now connect to the new test database
232
    logger.Info(ctx, "Connecting to database", map[string]interface{}{"database_url": databaseURL})
233

234
    // Initialize database manager with logger
235
    dbManager := database.NewManager(logger)
236
    db, err := dbManager.InitDB(databaseURL)
237
    if err != nil {
238
        logger.Error(ctx, "Failed to initialize database", err)
239
        os.Exit(1)
240
    }
241
    defer func() {
242
        if err := db.Close(); err != nil {
243
            logger.Warn(ctx, "Warning: failed to close database", map[string]interface{}{"error": err.Error()})
244
        }
245
    }()
246

247
    // Get the root directory (backend is the working directory)
248
    rootDir, err := os.Getwd()
249
    if err != nil {
250
        logger.Error(ctx, "Failed to get working directory", err)
251
        os.Exit(1)
252
    }
253

254
    // Apply schema from schema.sql
255
    schemaPath := filepath.Join(rootDir, "..", "schema.sql")
256
    if err := applySchema(db, schemaPath, rootDir, logger); err != nil {
257
        logger.Error(ctx, "Failed to apply schema", err)
258
        os.Exit(1)
259
    }
260

261
    // Initialize services
262
    userService := services.NewUserServiceWithLogger(db, cfg, logger)
263
    learningService := services.NewLearningServiceWithLogger(db, cfg, logger)
264
    // Create question service
265
    questionService := services.NewQuestionServiceWithLogger(db, learningService, cfg, logger)
266

267
    // Ensure admin user exists
268
    if err := userService.EnsureAdminUserExists(ctx, "admin", "password"); err != nil {
269
        logger.Error(ctx, "Failed to ensure admin user exists", err)
270
        os.Exit(1)
271
    }
272

273
    // Load and insert test data
274
    users, err := setupTestData(ctx, rootDir, userService, questionService, learningService, db, logger)
275
    if err != nil {
276
        logger.Error(ctx, "Failed to setup test data", err)
277
        os.Exit(1)
278
    }
279

280
    // Output user data to JSON file for E2E tests
281
    if err := outputUserDataForTests(users, rootDir, logger); err != nil {
282
        logger.Error(ctx, "Failed to output user data for tests", err)
283
        os.Exit(1)
284
    }
285

286
    // Output roles data to JSON file for E2E tests
287
    if err := outputRolesDataForTests(db, rootDir, logger); err != nil {
288
        logger.Error(ctx, "Failed to output roles data for tests", err)
289
        os.Exit(1)
290
    }
291

292
    logger.Info(ctx, "Test database created successfully")
293
}
294

295
func applySchema(db *sql.DB, schemaPath, _ string, logger *observability.Logger) error {
296
    ctx := context.Background()
297

298
    // First, drop all existing tables and sequences to ensure clean state
299
    logger.Info(ctx, "Dropping existing tables and sequences")
300
    dropSQL := `
301
        -- Drop tables in reverse dependency order
302
        DROP TABLE IF EXISTS performance_metrics CASCADE;
303
        DROP TABLE IF EXISTS user_responses CASCADE;
304
        DROP TABLE IF EXISTS questions CASCADE;
305
        DROP TABLE IF EXISTS users CASCADE;
306

307
        -- Drop any remaining sequences (in case they weren't cleaned up)
308
        DROP SEQUENCE IF EXISTS users_id_seq CASCADE;
309
        DROP SEQUENCE IF EXISTS questions_id_seq CASCADE;
310
        DROP SEQUENCE IF EXISTS user_responses_id_seq CASCADE;
311
        DROP SEQUENCE IF EXISTS performance_metrics_id_seq CASCADE;
312
    `
313

314
    if _, err := db.Exec(dropSQL); err != nil {
315
        return contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to drop existing tables: %w", err)
316
    }
317

318
    // Now apply the schema
319
    logger.Info(ctx, "Applying schema")
320
    schemaSQL, err := os.ReadFile(schemaPath)
321
    if err != nil {
322
        return contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to read schema file: %w", err)
323
    }
324

325
    if _, err := db.Exec(string(schemaSQL)); err != nil {
326
        return contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to execute schema: %w", err)
327
    }
328

329
    // Priority system tables are already included in the main schema.sql
330
    // No additional migration needed
331
    logger.Info(ctx, "Priority system tables already included in main schema")
332

333
    return nil
334
}
335

336
func setupTestData(ctx context.Context, rootDir string, userService *services.UserService, questionService *services.QuestionService, learningService *services.LearningService, db *sql.DB, logger *observability.Logger) (map[string]*models.User, error) {
337
    dataDir := filepath.Join(rootDir, "data")
338

339
    // 1. Load and create users
340
    users, err := loadAndCreateUsers(ctx, filepath.Join(dataDir, "test_users.yaml"), userService, logger)
341
    if err != nil {
342
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup users: %w", err)
343
    }
344

345
    // 2. Load and create questions
346
    questions, err := loadAndCreateQuestions(ctx, filepath.Join(dataDir, "test_questions.yaml"), questionService, users, logger)
347
    if err != nil {
348
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup questions: %w", err)
349
    }
350

351
    // 3. Load and create user responses
352
    if err := loadAndCreateResponses(ctx, filepath.Join(dataDir, "test_responses.yaml"), users, questions, learningService, logger); err != nil {
353
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup responses: %w", err)
354
    }
355

356
    // 4. Load and create question reports
357
    if err := loadAndCreateQuestionReports(ctx, filepath.Join(dataDir, "test_responses.yaml"), users, questions, db, logger); err != nil {
358
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup question reports: %w", err)
359
    }
360

361
    // 5. Load and create analytics data
362
    if err := loadAndCreateAnalytics(ctx, filepath.Join(dataDir, "test_analytics.yaml"), users, questions, learningService, db, logger); err != nil {
363
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup analytics: %w", err)
364
    }
365

366
    // 6. Load and create daily assignments
367
    if err := loadAndCreateDailyAssignments(ctx, filepath.Join(dataDir, "test_daily_assignments.yaml"), users, questions, db, logger); err != nil {
368
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to setup daily assignments: %w", err)
369
    }
370

371
    return users, nil
372
}
373

374
func loadAndCreateUsers(ctx context.Context, filePath string, userService *services.UserService, logger *observability.Logger) (result0 map[string]*models.User, err error) {
375
    data, err := os.ReadFile(filePath)
376
    if err != nil {
377
        return nil, err
378
    }
379

380
    var testUsers TestUsers
381
    if err := yaml.Unmarshal(data, &testUsers); err != nil {
382
        return nil, err
383
    }
384

385
    users := make(map[string]*models.User)
386
    for _, testUser := range testUsers.Users {
387
        // Create user with email and timezone
388
        user, err := userService.CreateUserWithEmailAndTimezone(
389
            ctx,
390
            testUser.Username,
391
            testUser.Email,
392
            "UTC", // Default timezone for test users
393
            testUser.PreferredLanguage,
394
            testUser.CurrentLevel,
395
        )
396
        if err != nil {
397
            return nil, contextutils.WrapErrorf(err, "failed to create user %s", testUser.Username)
398
        }
399

400
        // Set password separately since CreateUserWithEmailAndTimezone doesn't set password
401
        if err := userService.UpdateUserPassword(ctx, user.ID, testUser.Password); err != nil {
402
            return nil, contextutils.WrapErrorf(err, "failed to set password for user %s", testUser.Username)
403
        }
404

405
        // Update additional settings
406
        settings := &models.UserSettings{
407
            Language:   testUser.PreferredLanguage,
408
            Level:      testUser.CurrentLevel,
409
            AIProvider: testUser.AIProvider,
410
            AIModel:    testUser.AIModel,
411
            AIAPIKey:   testUser.AIAPIKey,
412
            AIEnabled:  testUser.AIProvider != "", // Enable AI if provider is set
413
        }
414

415
        if err := userService.UpdateUserSettings(ctx, user.ID, settings); err != nil {
416
            return nil, contextutils.WrapErrorf(err, "failed to update settings for user %s", testUser.Username)
417
        }
418

419
        // Assign roles from YAML configuration
420
        for _, roleName := range testUser.Roles {
421
            err = userService.AssignRoleByName(ctx, user.ID, roleName)
422
            if err != nil {
423
                logger.Warn(ctx, "Failed to assign role to user", map[string]interface{}{
424
                    "username": testUser.Username,
425
                    "role":     roleName,
426
                    "error":    err.Error(),
427
                })
428
            } else {
429
                logger.Info(ctx, "Assigned role to user", map[string]interface{}{
430
                    "username": testUser.Username,
431
                    "role":     roleName,
432
                    "user_id":  user.ID,
433
                })
434
            }
435
        }
436

437
        users[testUser.Username] = user
438
    }
439

440
    return users, nil
441
}
442

443
func loadAndCreateQuestions(ctx context.Context, filePath string, questionService *services.QuestionService, users map[string]*models.User, _ *observability.Logger) (result0 []*models.Question, err error) {
444
    data, err := os.ReadFile(filePath)
445
    if err != nil {
446
        return nil, err
447
    }
448

449
    var testQuestions TestQuestions
450
    if err := yaml.Unmarshal(data, &testQuestions); err != nil {
451
        return nil, err
452
    }
453

454
    var questions []*models.Question
455
    for i, question := range testQuestions.Questions {
456
        // Set the created time since it's not in YAML
457
        question.CreatedAt = time.Now()
458

459
        // Get the users this question should be assigned to
460
        questionUsers := question.Users
461
        var assignedUserIDs []int
462
        if len(questionUsers) == 0 {
463
            // Fallback to round-robin if no users specified
464
            for _, user := range users {
465
                assignedUserIDs = append(assignedUserIDs, user.ID)
466
            }
467
            if len(assignedUserIDs) == 0 {
468
                return nil, contextutils.ErrorWithContextf("no users available to assign questions to")
469
            }
470
            // Assign to one user in round-robin
471
            assignedUserIDs = []int{assignedUserIDs[i%len(assignedUserIDs)]}
472
        } else {
473
            for _, username := range questionUsers {
474
                user, exists := users[username]
475
                if !exists {
476
                    return nil, contextutils.ErrorWithContextf("user not found: %s", username)
477
                }
478
                assignedUserIDs = append(assignedUserIDs, user.ID)
479
            }
480
        }
481

482
        if err := questionService.SaveQuestion(ctx, &question); err != nil {
483
            return nil, contextutils.WrapErrorf(err, "failed to save question %d", i)
484
        }
485

486
        for _, userID := range assignedUserIDs {
487
            if err := questionService.AssignQuestionToUser(ctx, question.ID, userID); err != nil {
488
                return nil, contextutils.WrapErrorf(err, "failed to assign question %d to user %d", question.ID, userID)
489
            }
490
        }
491

492
        questions = append(questions, &question)
493
    }
494

495
    return questions, nil
496
}
497

498
func loadAndCreateResponses(_ context.Context, filePath string, users map[string]*models.User, questions []*models.Question, learningService *services.LearningService, _ *observability.Logger) error {
499
    data, err := os.ReadFile(filePath)
500
    if err != nil {
501
        return err
502
    }
503

504
    var testResponses TestResponses
505
    if err := yaml.Unmarshal(data, &testResponses); err != nil {
506
        return err
507
    }
508

509
    for i, responseData := range testResponses.UserResponses {
510
        user, exists := users[responseData.Username]
511
        if !exists {
512
            return contextutils.ErrorWithContextf("user not found: %s", responseData.Username)
513
        }
514

515
        if responseData.QuestionIndex >= len(questions) {
516
            return contextutils.ErrorWithContextf("question index out of range: %d", responseData.QuestionIndex)
517
        }
518

519
        question := questions[responseData.QuestionIndex]
520

521
        // Use RecordAnswerWithPriority to ensure priority scores are calculated
522
        if err := learningService.RecordAnswerWithPriority(
523
            context.Background(),
524
            user.ID,
525
            question.ID,
526
            0, // Use index 0 for test data
527
            responseData.IsCorrect,
528
            responseData.ResponseTimeMs,
529
        ); err != nil {
530
            return contextutils.WrapErrorf(err, "failed to record response %d", i)
531
        }
532

533
    }
534

535
    return nil
536
}
537

538
func loadAndCreateQuestionReports(_ context.Context, filePath string, users map[string]*models.User, questions []*models.Question, db *sql.DB, _ *observability.Logger) error {
539
    data, err := os.ReadFile(filePath)
540
    if err != nil {
541
        return contextutils.WrapError(err, "failed to read responses file")
542
    }
543

544
    var testResponses TestResponses
545
    if err := yaml.Unmarshal(data, &testResponses); err != nil {
546
        return contextutils.WrapError(err, "failed to parse responses data")
547
    }
548

549
    // Load question reports
550
    for i, reportData := range testResponses.QuestionReports {
551
        user, exists := users[reportData.Username]
552
        if !exists {
553
            return contextutils.ErrorWithContextf("user not found for question report: %s", reportData.Username)
554
        }
555

556
        if reportData.QuestionIndex >= len(questions) {
557
            return contextutils.ErrorWithContextf("question index out of range for question report: %d", reportData.QuestionIndex)
558
        }
559

560
        question := questions[reportData.QuestionIndex]
561

562
        // Parse the timestamp if provided, otherwise use current time
563
        var createdAt time.Time
564
        if reportData.CreatedAt != nil {
565
            var err error
566
            createdAt, err = time.Parse(time.RFC3339, *reportData.CreatedAt)
567
            if err != nil {
568
                return contextutils.ErrorWithContextf("invalid timestamp format for question report: %s", *reportData.CreatedAt)
569
            }
570
        } else {
571
            createdAt = time.Now()
572
        }
573

574
        // Insert question report directly into database
575
        _, err := db.Exec(`
576
            INSERT INTO question_reports (question_id, reported_by_user_id, report_reason, created_at)
577
            VALUES ($1, $2, $3, $4)
578
            ON CONFLICT (question_id, reported_by_user_id) DO NOTHING
579
        `, question.ID, user.ID, reportData.ReportReason, createdAt)
580
        if err != nil {
581
            return contextutils.WrapErrorf(err, "failed to insert question report %d", i)
582
        }
583
    }
584

585
    return nil
586
}
587

588
func loadAndCreateAnalytics(ctx context.Context, filePath string, users map[string]*models.User, questions []*models.Question, learningService *services.LearningService, db *sql.DB, logger *observability.Logger) error {
589
    data, err := os.ReadFile(filePath)
590
    if err != nil {
591
        // Analytics file is optional, so just return if it doesn't exist
592
        logger.Warn(ctx, "Analytics file not found", map[string]interface{}{"file_path": filePath})
593
        return nil
594
    }
595

596
    var testAnalytics TestAnalytics
597
    if err := yaml.Unmarshal(data, &testAnalytics); err != nil {
598
        return contextutils.WrapError(err, "failed to parse analytics data")
599
    }
600

601
    // Load priority scores
602
    for _, priorityData := range testAnalytics.PriorityScores {
603
        user, exists := users[priorityData.Username]
604
        if !exists {
605
            return contextutils.ErrorWithContextf("user not found for priority score: %s", priorityData.Username)
606
        }
607

608
        if priorityData.QuestionIndex >= len(questions) {
609
            return contextutils.ErrorWithContextf("question index out of range for priority score: %d", priorityData.QuestionIndex)
610
        }
611

612
        question := questions[priorityData.QuestionIndex]
613

614
        // Parse the timestamp
615
        lastCalculatedAt, err := time.Parse(time.RFC3339, priorityData.LastCalculatedAt)
616
        if err != nil {
617
            return contextutils.ErrorWithContextf("invalid timestamp format for priority score: %s", priorityData.LastCalculatedAt)
618
        }
619

620
        // Insert priority score directly into database
621
        _, err = db.Exec(`
622
            INSERT INTO question_priority_scores (user_id, question_id, priority_score, last_calculated_at, created_at, updated_at)
623
            VALUES ($1, $2, $3, $4, NOW(), NOW())
624
            ON CONFLICT (user_id, question_id) DO UPDATE SET
625
                priority_score = EXCLUDED.priority_score,
626
                last_calculated_at = EXCLUDED.last_calculated_at,
627
                updated_at = NOW()
628
        `, user.ID, question.ID, priorityData.PriorityScore, lastCalculatedAt)
629
        if err != nil {
630
            return contextutils.WrapError(err, "failed to insert priority score")
631
        }
632

633
    }
634

635
    // Load learning preferences
636
    for _, prefData := range testAnalytics.LearningPreferences {
637
        user, exists := users[prefData.Username]
638
        if !exists {
639
            return contextutils.ErrorWithContextf("user not found for learning preferences: %s", prefData.Username)
640
        }
641

642
        // Ensure daily_goal is present and valid. The schema enforces daily_goal > 0
643
        // so default to the service's default if not provided or invalid.
644
        dailyGoal := 0
645
        // Try to parse a daily_goal field if it exists in the YAML by checking for a map
646
        // fallback: the YAML struct doesn't include daily_goal currently; use default
647
        // from the LearningService defaults.
648
        // We'll fetch defaults from service to avoid duplicating magic numbers.
649
        defaultPrefs := learningService.GetDefaultLearningPreferences()
650
        if dailyGoal <= 0 {
651
            dailyGoal = defaultPrefs.DailyGoal
652
        }
653

654
        prefs := &models.UserLearningPreferences{
655
            UserID:               user.ID,
656
            FocusOnWeakAreas:     prefData.FocusOnWeakAreas,
657
            FreshQuestionRatio:   prefData.FreshQuestionRatio,
658
            WeakAreaBoost:        prefData.WeakAreaBoost,
659
            KnownQuestionPenalty: prefData.KnownQuestionPenalty,
660
            ReviewIntervalDays:   prefData.ReviewIntervalDays,
661
            DailyReminderEnabled: prefData.DailyReminderEnabled,
662
            DailyGoal:            dailyGoal,
663
        }
664

665
        if _, err := learningService.UpdateUserLearningPreferences(ctx, user.ID, prefs); err != nil {
666
            return contextutils.WrapErrorf(err, "failed to update learning preferences for user %s", prefData.Username)
667
        }
668

669
    }
670

671
    // Load performance metrics
672
    for _, metricData := range testAnalytics.PerformanceMetrics {
673
        user, exists := users[metricData.Username]
674
        if !exists {
675
            return contextutils.ErrorWithContextf("user not found for performance metrics: %s", metricData.Username)
676
        }
677

678
        // Insert performance metric directly into database
679
        _, err := db.Exec(`
680
            INSERT INTO performance_metrics (user_id, topic, language, level, total_attempts, correct_attempts, average_response_time_ms, last_updated)
681
            VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
682
            ON CONFLICT (user_id, topic, language, level) DO UPDATE SET
683
                total_attempts = EXCLUDED.total_attempts,
684
                correct_attempts = EXCLUDED.correct_attempts,
685
                average_response_time_ms = EXCLUDED.average_response_time_ms,
686
                last_updated = NOW()
687
        `, user.ID, metricData.Topic, metricData.Language, metricData.Level,
688
            metricData.TotalAttempts, metricData.CorrectAttempts, metricData.AverageResponseTimeMs)
689
        if err != nil {
690
            return contextutils.WrapError(err, "failed to insert performance metric")
691
        }
692

693
    }
694

695
    // Load user question metadata (marked as known)
696
    for _, metadata := range testAnalytics.UserQuestionMetadata {
697
        user, exists := users[metadata.Username]
698
        if !exists {
699
            return contextutils.ErrorWithContextf("user not found for question metadata: %s", metadata.Username)
700
        }
701

702
        if metadata.QuestionIndex >= len(questions) {
703
            return contextutils.ErrorWithContextf("question index out of range for metadata: %d", metadata.QuestionIndex)
704
        }
705

706
        question := questions[metadata.QuestionIndex]
707

708
        if metadata.MarkedAsKnown {
709
            var markedAt time.Time
710
            if metadata.MarkedAsKnownAt != nil {
711
                var err error
712
                markedAt, err = time.Parse(time.RFC3339, *metadata.MarkedAsKnownAt)
713
                if err != nil {
714
                    return contextutils.ErrorWithContextf("invalid timestamp format for marked as known: %s", *metadata.MarkedAsKnownAt)
715
                }
716
            } else {
717
                markedAt = time.Now()
718
            }
719

720
            // Insert into user_question_metadata table
721
            _, err := db.Exec(`
722
                INSERT INTO user_question_metadata (user_id, question_id, marked_as_known, marked_as_known_at, created_at, updated_at)
723
                VALUES ($1, $2, $3, $4, NOW(), NOW())
724
                ON CONFLICT (user_id, question_id) DO UPDATE SET
725
                    marked_as_known = EXCLUDED.marked_as_known,
726
                    marked_as_known_at = EXCLUDED.marked_as_known_at,
727
                    updated_at = NOW()
728
            `, user.ID, question.ID, metadata.MarkedAsKnown, markedAt)
729
            if err != nil {
730
                return contextutils.WrapError(err, "failed to insert question metadata")
731
            }
732

733
        }
734
    }
735

736
    return nil
737
}
738

739
func loadAndCreateDailyAssignments(ctx context.Context, filePath string, users map[string]*models.User, questions []*models.Question, db *sql.DB, logger *observability.Logger) error {
740
    data, err := os.ReadFile(filePath)
741
    if err != nil {
742
        // File doesn't exist, skip daily assignments
743
        logger.Info(ctx, "Daily assignments file not found, skipping", map[string]interface{}{
744
            "file_path": filePath,
745
        })
746
        return nil
747
    }
748

749
    var testDailyAssignments TestDailyAssignments
750
    if err := yaml.Unmarshal(data, &testDailyAssignments); err != nil {
751
        return err
752
    }
753

754
    for _, assignmentData := range testDailyAssignments.DailyAssignments {
755
        user, exists := users[assignmentData.Username]
756
        if !exists {
757
            logger.Warn(ctx, "User not found for daily assignment", map[string]interface{}{
758
                "username": assignmentData.Username,
759
            })
760
            continue
761
        }
762

763
        // Parse the date
764
        date, err := time.Parse("2006-01-02", assignmentData.Date)
765
        if err != nil {
766
            logger.Warn(ctx, "Invalid date format for daily assignment", map[string]interface{}{
767
                "username": assignmentData.Username,
768
                "date":     assignmentData.Date,
769
            })
770
            continue
771
        }
772

773
        // Create a map of completed questions for quick lookup
774
        completedQuestions := make(map[int]bool)
775
        for _, qID := range assignmentData.CompletedQuestions {
776
            completedQuestions[qID] = true
777
        }
778

779
        // Assign questions to the user for the specific date
780
        for _, questionID := range assignmentData.QuestionIDs {
781
            // Check if question exists
782
            if questionID <= 0 || questionID > len(questions) {
783
                logger.Warn(ctx, "Question ID out of range for daily assignment", map[string]interface{}{
784
                    "username":    assignmentData.Username,
785
                    "date":        assignmentData.Date,
786
                    "question_id": questionID,
787
                })
788
                continue
789
            }
790

791
            question := questions[questionID-1] // Convert to 0-based index
792

793
            // Ensure we don't violate unique constraint by removing any existing assignment for the same
794
            // (user_id, question_id, assignment_date) tuple before inserting. This avoids relying on
795
            // ON CONFLICT which requires the constraint to be present in some test DB states.
796
            deleteQuery := `DELETE FROM daily_question_assignments WHERE user_id = $1 AND question_id = $2 AND assignment_date = $3`
797
            if _, err := db.ExecContext(ctx, deleteQuery, user.ID, question.ID, date); err != nil {
798
                logger.Error(ctx, "Failed to delete existing daily assignment", err, map[string]interface{}{
799
                    "username":    assignmentData.Username,
800
                    "date":        assignmentData.Date,
801
                    "question_id": questionID,
802
                })
803
                return contextutils.WrapErrorf(err, "failed to delete existing daily assignment for user %s, question %d", assignmentData.Username, questionID)
804
            }
805

806
            // Insert the assignment directly into the database
807
            query := `
808
                INSERT INTO daily_question_assignments (user_id, question_id, assignment_date, is_completed, completed_at)
809
                VALUES ($1, $2, $3, $4, $5)
810
            `
811

812
            isCompleted := completedQuestions[questionID]
813
            var completedAt *time.Time
814
            if isCompleted {
815
                now := time.Now()
816
                completedAt = &now
817
            }
818

819
            if _, err := db.ExecContext(ctx, query, user.ID, question.ID, date, isCompleted, completedAt); err != nil {
820
                logger.Error(ctx, "Failed to create daily assignment", err, map[string]interface{}{
821
                    "username":    assignmentData.Username,
822
                    "date":        assignmentData.Date,
823
                    "question_id": questionID,
824
                })
825
                return contextutils.WrapErrorf(err, "failed to create daily assignment for user %s, question %d", assignmentData.Username, questionID)
826
            }
827
        }
828

829
        logger.Info(ctx, "Created daily assignments", map[string]interface{}{
830
            "username": assignmentData.Username,
831
            "date":     assignmentData.Date,
832
            "count":    len(assignmentData.QuestionIDs),
833
        })
834
    }
835

836
    return nil
837
}
838

839
// outputUserDataForTests outputs the created user data to a JSON file for E2E tests to read
840
func outputUserDataForTests(users map[string]*models.User, rootDir string, logger *observability.Logger) error {
841
    // Create a simplified structure for the E2E test
842
    type TestUserData struct {
843
        ID       int    `json:"id"`
844
        Username string `json:"username"`
845
        Email    string `json:"email"`
846
    }
847

848
    userData := make(map[string]TestUserData)
849
    for username, user := range users {
850
        userData[username] = TestUserData{
851
            ID:       user.ID,
852
            Username: user.Username,
853
            Email:    user.Email.String,
854
        }
855
    }
856

857
    // Write to JSON file in the frontend/tests directory
858
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-users.json")
859

860
    // Ensure the directory exists
861
    outputDir := filepath.Dir(outputPath)
862
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
863
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
864
    }
865

866
    // Marshal to JSON with pretty printing
867
    jsonData, err := json.MarshalIndent(userData, "", "  ")
868
    if err != nil {
869
        return contextutils.WrapErrorf(err, "failed to marshal user data to JSON")
870
    }
871

872
    // Write to file
873
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
874
        return contextutils.WrapErrorf(err, "failed to write user data to file: %s", outputPath)
875
    }
876

877
    logger.Info(context.Background(), "Output user data for E2E tests", map[string]interface{}{
878
        "file_path":  outputPath,
879
        "user_count": len(userData),
880
    })
881

882
    return nil
883
}
884

885
// outputRolesDataForTests outputs the created roles data to a JSON file for E2E tests to read
886
func outputRolesDataForTests(db *sql.DB, rootDir string, logger *observability.Logger) error {
887
    // Query all roles from the database
888
    rows, err := db.Query(`
889
        SELECT id, name, description, created_at, updated_at
890
        FROM roles
891
        ORDER BY id
892
    `)
893
    if err != nil {
894
        return contextutils.WrapErrorf(err, "failed to query roles from database")
895
    }
896
    defer func() {
897
        if err := rows.Close(); err != nil {
898
            logger.Warn(context.Background(), "Warning: failed to close rows", map[string]interface{}{"error": err.Error()})
899
        }
900
    }()
901

902
    // Create a simplified structure for the E2E test
903
    type TestRoleData struct {
904
        ID          int    `json:"id"`
905
        Name        string `json:"name"`
906
        Description string `json:"description"`
907
    }
908

909
    roleData := make(map[string]TestRoleData)
910
    for rows.Next() {
911
        var role models.Role
912
        err := rows.Scan(&role.ID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt)
913
        if err != nil {
914
            return contextutils.WrapErrorf(err, "failed to scan role data")
915
        }
916
        roleData[role.Name] = TestRoleData{
917
            ID:          role.ID,
918
            Name:        role.Name,
919
            Description: role.Description,
920
        }
921
    }
922

923
    if err := rows.Err(); err != nil {
924
        return contextutils.WrapErrorf(err, "error iterating over roles")
925
    }
926

927
    // Write to JSON file in the frontend/tests directory
928
    outputPath := filepath.Join(rootDir, "..", "frontend", "tests", "test-roles.json")
929

930
    // Ensure the directory exists
931
    outputDir := filepath.Dir(outputPath)
932
    if err := os.MkdirAll(outputDir, 0o755); err != nil {
933
        return contextutils.WrapErrorf(err, "failed to create output directory: %s", outputDir)
934
    }
935

936
    // Marshal to JSON with pretty printing
937
    jsonData, err := json.MarshalIndent(roleData, "", "  ")
938
    if err != nil {
939
        return contextutils.WrapErrorf(err, "failed to marshal roles data to JSON")
940
    }
941

942
    // Write to file
943
    if err := os.WriteFile(outputPath, jsonData, 0o644); err != nil {
944
        return contextutils.WrapErrorf(err, "failed to write roles data to file: %s", outputPath)
945
    }
946

947
    logger.Info(context.Background(), "Output roles data for E2E tests", map[string]interface{}{
948
        "file_path":   outputPath,
949
        "roles_count": len(roleData),
950
    })
951

952
    return nil
953
}
954


			
quizapp cmd worker
0.0%
Statements
0/141
main.go
0.0%
0/141
quizapp cmd worker main.go
0.0%
Statements
0/141
1
// Package main provides the entry point for the Quiz Application worker service.
2
package main
3

4
import (
5
    "context"
6
    "io/fs"
7
    "net/http"
8
    "os"
9
    "os/signal"
10
    "syscall"
11
    "time"
12

13
    "quizapp/internal/config"
14
    "quizapp/internal/database"
15
    "quizapp/internal/handlers"
16
    "quizapp/internal/middleware"
17
    "quizapp/internal/observability"
18
    "quizapp/internal/services"
19
    "quizapp/internal/version"
20
    "quizapp/internal/worker"
21

22
    "github.com/gin-contrib/sessions"
23
    "github.com/gin-contrib/sessions/cookie"
24
    "github.com/gin-gonic/gin"
25
)
26

27
// fatalIfErr logs the error with context and panics with a consistent message
28
func fatalIfErr(ctx context.Context, logger *observability.Logger, msg string, err error, fields map[string]interface{}) {
29
    logger.Error(ctx, msg, err, fields)
30
    panic(msg + ": " + err.Error())
31
}
32

33
func main() {
34
    ctx := context.Background()
35

36
    // Load configuration
37
    cfg, err := config.NewConfig()
38
    if err != nil {
39
        panic("Failed to load configuration: " + err.Error())
40
    }
41

42
    // Setup observability (tracing/metrics/logging)
43
    tp, mp, logger, err := observability.SetupObservability(&cfg.OpenTelemetry, "quiz-worker")
44
    if err != nil {
45
        panic("Failed to initialize observability: " + err.Error())
46
    }
47
    defer func() {
48
        if tp != nil {
49
            if err := tp.Shutdown(context.TODO()); err != nil {
50
                logger.Warn(ctx, "Error shutting down tracer provider", map[string]interface{}{"error": err.Error(), "provider": "tracer"})
51
            }
52
        }
53
        if mp != nil {
54
            if err := mp.Shutdown(context.TODO()); err != nil {
55
                logger.Warn(ctx, "Error shutting down meter provider", map[string]interface{}{"error": err.Error(), "provider": "meter"})
56
            }
57
        }
58
    }()
59

60
    logger.Info(ctx, "Starting quiz worker service", map[string]interface{}{
61
        "port":     cfg.Server.WorkerPort,
62
        "logLevel": cfg.Server.LogLevel,
63
        "debug":    cfg.Server.Debug,
64
    })
65

66
    // Initialize database manager with logger
67
    dbManager := database.NewManager(logger)
68

69
    // Initialize database connection with configuration (no migrations for worker)
70
    db, err := dbManager.InitDBWithoutMigrations(cfg.Database)
71
    if err != nil {
72
        fatalIfErr(ctx, logger, "Failed to initialize database", err, map[string]interface{}{"db_url": cfg.Database.URL})
73
    }
74
    defer func() {
75
        if err := db.Close(); err != nil {
76
            logger.Warn(ctx, "Warning: failed to close database", map[string]interface{}{"error": err.Error(), "db_url": cfg.Database.URL})
77
        }
78
    }()
79

80
    // Initialize services
81
    userService := services.NewUserServiceWithLogger(db, cfg, logger)
82
    learningService := services.NewLearningServiceWithLogger(db, cfg, logger)
83
    // Create question service
84
    questionService := services.NewQuestionServiceWithLogger(db, learningService, cfg, logger)
85
    aiService := services.NewAIService(cfg, logger)
86
    workerService := services.NewWorkerServiceWithLogger(db, logger)
87
    generationHintService := services.NewGenerationHintService(db, logger)
88
    emailService := services.CreateEmailServiceWithDB(cfg, logger, db)
89
    // Create daily question service
90
    dailyQuestionService := services.NewDailyQuestionService(db, logger, questionService, learningService)
91

92
    // Initialize worker with the observability logger
93
    workerInstance := worker.NewWorker(userService, questionService, aiService, learningService, workerService, dailyQuestionService, emailService, generationHintService, "default", cfg, logger)
94
    go workerInstance.Start(ctx)
95

96
    // Initialize admin handler for worker UI
97
    adminHandler := handlers.NewWorkerAdminHandlerWithLogger(userService, questionService, aiService, cfg, workerInstance, workerService, learningService, dailyQuestionService, logger)
98

99
    // Setup Gin router
100
    gin.SetMode(gin.ReleaseMode)
101
    if cfg.Server.Debug {
102
        gin.SetMode(gin.DebugMode)
103
    }
104
    router := gin.New()
105
    router.Use(gin.Recovery())
106

107
    // Add HTTP request logging middleware using our observability logger
108
    router.Use(func(c *gin.Context) {
109
        start := time.Now()
110

111
        // Process request
112
        c.Next()
113

114
        // Log request details using our observability logger
115
        latency := time.Since(start)
116
        statusCode := c.Writer.Status()
117
        clientIP := c.ClientIP()
118
        method := c.Request.Method
119
        path := c.Request.URL.Path
120

121
        // Create structured log entry
122
        fields := map[string]interface{}{
123
            "http.method":      method,
124
            "http.path":        path,
125
            "http.status_code": statusCode,
126
            "http.latency_ms":  latency.Milliseconds(),
127
            "http.client_ip":   clientIP,
128
            "http.user_agent":  c.Request.UserAgent(),
129
        }
130

131
        // Add error message if present
132
        if len(c.Errors) > 0 {
133
            fields["http.error"] = c.Errors.String()
134
        }
135

136
        // Log using our observability logger (goes to both stdout and OTLP)
137
        // Use appropriate log level based on status code
138
        if statusCode >= 500 {
139
            logger.Error(c.Request.Context(), "HTTP request failed", nil, fields)
140
        } else if statusCode >= 400 {
141
            logger.Warn(c.Request.Context(), "HTTP request warning", fields)
142
        } else {
143
            logger.Info(c.Request.Context(), "HTTP request", fields)
144
        }
145
    })
146

147
    // Add OpenTelemetry middleware for HTTP tracing with automatic error attributes
148
    router.Use(observability.GinMiddlewareWithErrorHandling("quiz-worker"))
149

150
    // Add CORS middleware
151
    router.Use(func(c *gin.Context) {
152
        c.Header("Access-Control-Allow-Origin", "*")
153
        c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
154
        c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
155

156
        if c.Request.Method == "OPTIONS" {
157
            c.AbortWithStatus(204)
158
            return
159
        }
160

161
        c.Next()
162
    })
163

164
    // Setup session middleware
165
    store := cookie.NewStore([]byte(cfg.Server.SessionSecret))
166
    router.Use(sessions.Sessions(config.SessionName, store))
167

168
    // Setup routes
169
    v1 := router.Group("/v1")
170
    {
171
        // Health check route
172
        v1.GET("/health", func(c *gin.Context) {
173
            c.JSON(http.StatusOK, gin.H{"status": "ok"})
174
        })
175

176
        // Version route
177
        v1.GET("/version", func(c *gin.Context) {
178
            c.JSON(http.StatusOK, gin.H{
179
                "service":   "worker",
180
                "version":   version.Version,
181
                "commit":    version.Commit,
182
                "buildTime": version.BuildTime,
183
            })
184
        })
185
    }
186

187
    // Serve static assets (CSS/JS) for worker admin dashboard
188
    staticFS, _ := fs.Sub(handlers.AssetsFS, "templates/assets")
189
    router.StaticFS("/worker", http.FS(staticFS))
190

191
    // Config dump endpoint
192
    router.GET("/configz", adminHandler.GetConfigz)
193

194
    // API routes for worker management
195
    api := router.Group("/v1")
196
    {
197
        // Admin worker endpoints (for frontend)
198
        adminWorker := api.Group("/admin/worker")
199
        adminWorker.Use(middleware.RequireAuth())
200
        {
201
            adminWorker.GET("/details", adminHandler.GetWorkerDetails)
202
            adminWorker.GET("/status", adminHandler.GetWorkerStatus)
203
            adminWorker.GET("/logs", adminHandler.GetActivityLogs)
204
            adminWorker.POST("/pause", adminHandler.PauseWorker)
205
            adminWorker.POST("/resume", adminHandler.ResumeWorker)
206
            adminWorker.POST("/trigger", adminHandler.TriggerWorkerRun)
207
            adminWorker.GET("/ai-concurrency", adminHandler.GetAIConcurrencyStats)
208
        }
209

210
        // Worker user control endpoints (for pausing/resuming user question generation)
211
        workerUsers := api.Group("/admin/worker/users")
212
        workerUsers.Use(middleware.RequireAuth())
213
        {
214
            workerUsers.GET("/", adminHandler.GetWorkerUsers)
215
            workerUsers.POST("/pause", adminHandler.PauseWorkerUser)
216
            workerUsers.POST("/resume", adminHandler.ResumeWorkerUser)
217
        }
218

219
        // System health for worker
220
        system := api.Group("/system")
221
        {
222
            system.GET("/health", adminHandler.GetSystemHealth)
223
        }
224

225
        // Admin analytics endpoints (for frontend)
226
        adminAnalytics := api.Group("/admin/worker/analytics")
227
        adminAnalytics.Use(middleware.RequireAuth())
228
        {
229
            adminAnalytics.GET("/priority-scores", adminHandler.GetPriorityAnalytics)
230
            adminAnalytics.GET("/user-performance", adminHandler.GetUserPerformanceAnalytics)
231
            adminAnalytics.GET("/generation-intelligence", adminHandler.GetGenerationIntelligence)
232
            adminAnalytics.GET("/system-health", adminHandler.GetSystemHealthAnalytics)
233
            adminAnalytics.GET("/comparison", adminHandler.GetUserComparisonAnalytics)
234
            adminAnalytics.GET("/user/:userID", adminHandler.GetUserPriorityAnalytics)
235
        }
236

237
        // Admin daily questions endpoints (for frontend)
238
        adminDaily := api.Group("/admin/worker/daily")
239
        adminDaily.Use(middleware.RequireAuth())
240
        {
241
            adminDaily.GET("/users/:userId/questions/:date", adminHandler.GetUserDailyQuestions)
242
            adminDaily.POST("/users/:userId/questions/:date/regenerate", adminHandler.RegenerateUserDailyQuestions)
243
        }
244

245
        // Admin notification endpoints (for frontend)
246
        adminNotifications := api.Group("/admin/worker/notifications")
247
        adminNotifications.Use(middleware.RequireAuth())
248
        {
249
            adminNotifications.GET("/stats", adminHandler.GetNotificationStats)
250
            adminNotifications.GET("/errors", adminHandler.GetNotificationErrors)
251
            adminNotifications.GET("/sent", adminHandler.GetSentNotifications)
252
            adminNotifications.POST("/test/create-sent", adminHandler.CreateTestSentNotification)
253
            adminNotifications.POST("/force-send", adminHandler.ForceSendNotification)
254
        }
255
    }
256

257
    // Automatic route listing at root path
258
    routeListing := handlers.NewRouteListingHandler("Worker")
259
    routeListing.CollectRoutes(router)
260

261
    // Root path shows all available routes
262
    router.GET("/", func(c *gin.Context) {
263
        // Support JSON output via query parameter
264
        if c.Query("json") == "true" {
265
            routeListing.GetRouteListingJSON(c)
266
        } else {
267
            routeListing.GetRouteListingPage(c)
268
        }
269
    })
270

271
    // Create HTTP server
272
    srv := &http.Server{
273
        Addr:    ":" + cfg.Server.WorkerPort,
274
        Handler: router,
275
    }
276

277
    // Start server in a goroutine
278
    go func() {
279
        logger.Info(ctx, "Worker server starting", map[string]interface{}{"port": cfg.Server.WorkerPort})
280
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
281
            fatalIfErr(ctx, logger, "Failed to start worker server", err, map[string]interface{}{"port": cfg.Server.WorkerPort})
282
        }
283
    }()
284

285
    // Wait for interrupt signal to gracefully shutdown
286
    quit := make(chan os.Signal, 1)
287
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
288
    <-quit
289
    logger.Info(ctx, "Worker server shutting down", map[string]interface{}{"service": "worker"})
290

291
    // Graceful shutdown with timeout
292
    shutdownCtx, shutdownCancel := context.WithTimeout(ctx, config.WorkerShutdownTimeout)
293
    defer shutdownCancel()
294

295
    // Shutdown the worker first
296
    if err := workerInstance.Shutdown(shutdownCtx); err != nil {
297
        logger.Warn(ctx, "Warning: failed to shutdown worker", map[string]interface{}{"error": err.Error(), "service": "worker"})
298
    }
299

300
    // Then shutdown the server
301
    if err := srv.Shutdown(shutdownCtx); err != nil {
302
        fatalIfErr(ctx, logger, "Worker server forced to shutdown", err, map[string]interface{}{"service": "worker"})
303
    }
304

305
    logger.Info(ctx, "Worker server exited", map[string]interface{}{"service": "worker"})
306
}
307


			
quizapp internal
59.1%
Statements
6416/10851
api
0.0%
0/240
config
89.6%
121/135
database
72.6%
159/219
di
86.3%
82/95
handlers
58.5%
1877/3206
middleware
12.0%
71/593
models
100.0%
27/27
observability
52.6%
113/215
services
62.9%
3263/5190
utils
70.8%
143/202
worker
76.8%
560/729
quizapp internal api
0.0%
Statements
0/240
generated.go
0.0%
0/240
quizapp internal api generated.go
0.0%
Statements
0/240
1
// Package api provides primitives to interact with the openapi HTTP API.
2
//
3
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT.
4
package api
5

6
import (
7
    "encoding/json"
8
    "fmt"
9

10
    "github.com/oapi-codegen/runtime"
11
    openapi_types "github.com/oapi-codegen/runtime/types"
12
)
13

14
const (
15
    CookieAuthScopes  = "cookieAuth.Scopes"
16
    SessionAuthScopes = "sessionAuth.Scopes"
17
)
18

19
// Defines values for ChatMessageRole.
20
const (
21
    ChatMessageRoleAssistant ChatMessageRole = "assistant"
22
    ChatMessageRoleUser      ChatMessageRole = "user"
23
)
24

25
// Defines values for NotificationErrorErrorType.
26
const (
27
    NotificationErrorErrorTypeEmailDisabled NotificationErrorErrorType = "email_disabled"
28
    NotificationErrorErrorTypeOther         NotificationErrorErrorType = "other"
29
    NotificationErrorErrorTypeSmtpError     NotificationErrorErrorType = "smtp_error"
30
    NotificationErrorErrorTypeTemplateError NotificationErrorErrorType = "template_error"
31
    NotificationErrorErrorTypeUserNotFound  NotificationErrorErrorType = "user_not_found"
32
)
33

34
// Defines values for NotificationErrorNotificationType.
35
const (
36
    NotificationErrorNotificationTypeDailyReminder NotificationErrorNotificationType = "daily_reminder"
37
    NotificationErrorNotificationTypeTestEmail     NotificationErrorNotificationType = "test_email"
38
)
39

40
// Defines values for QuestionStatus.
41
const (
42
    Active   QuestionStatus = "active"
43
    Reported QuestionStatus = "reported"
44
)
45

46
// Defines values for QuestionType.
47
const (
48
    FillBlank            QuestionType = "fill_blank"
49
    Qa                   QuestionType = "qa"
50
    ReadingComprehension QuestionType = "reading_comprehension"
51
    Vocabulary           QuestionType = "vocabulary"
52
)
53

54
// Defines values for SentNotificationNotificationType.
55
const (
56
    SentNotificationNotificationTypeDailyReminder SentNotificationNotificationType = "daily_reminder"
57
    SentNotificationNotificationTypeTestEmail     SentNotificationNotificationType = "test_email"
58
)
59

60
// Defines values for SentNotificationStatus.
61
const (
62
    SentNotificationStatusBounced SentNotificationStatus = "bounced"
63
    SentNotificationStatusFailed  SentNotificationStatus = "failed"
64
    SentNotificationStatusSent    SentNotificationStatus = "sent"
65
)
66

67
// Defines values for TTSRequestStreamFormat.
68
const (
69
    Mp3 TTSRequestStreamFormat = "mp3"
70
    Sse TTSRequestStreamFormat = "sse"
71
    Wav TTSRequestStreamFormat = "wav"
72
)
73

74
// Defines values for TTSResponseType.
75
const (
76
    TTSResponseTypeAudio TTSResponseType = "audio"
77
    TTSResponseTypeError TTSResponseType = "error"
78
    TTSResponseTypeUsage TTSResponseType = "usage"
79
)
80

81
// Defines values for WorkerStatusStatus.
82
const (
83
    WorkerStatusStatusBusy  WorkerStatusStatus = "busy"
84
    WorkerStatusStatusError WorkerStatusStatus = "error"
85
    WorkerStatusStatusIdle  WorkerStatusStatus = "idle"
86
)
87

88
// Defines values for GetV1AdminBackendUserzPaginatedParamsAiEnabled.
89
const (
90
    GetV1AdminBackendUserzPaginatedParamsAiEnabledFalse GetV1AdminBackendUserzPaginatedParamsAiEnabled = "false"
91
    GetV1AdminBackendUserzPaginatedParamsAiEnabledTrue  GetV1AdminBackendUserzPaginatedParamsAiEnabled = "true"
92
)
93

94
// Defines values for GetV1AdminBackendUserzPaginatedParamsActive.
95
const (
96
    GetV1AdminBackendUserzPaginatedParamsActiveFalse GetV1AdminBackendUserzPaginatedParamsActive = "false"
97
    GetV1AdminBackendUserzPaginatedParamsActiveTrue  GetV1AdminBackendUserzPaginatedParamsActive = "true"
98
)
99

100
// Defines values for GetV1AdminWorkerNotificationsErrorsParamsErrorType.
101
const (
102
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeEmailDisabled GetV1AdminWorkerNotificationsErrorsParamsErrorType = "email_disabled"
103
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeOther         GetV1AdminWorkerNotificationsErrorsParamsErrorType = "other"
104
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeSmtpError     GetV1AdminWorkerNotificationsErrorsParamsErrorType = "smtp_error"
105
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeTemplateError GetV1AdminWorkerNotificationsErrorsParamsErrorType = "template_error"
106
    GetV1AdminWorkerNotificationsErrorsParamsErrorTypeUserNotFound  GetV1AdminWorkerNotificationsErrorsParamsErrorType = "user_not_found"
107
)
108

109
// Defines values for GetV1AdminWorkerNotificationsErrorsParamsNotificationType.
110
const (
111
    GetV1AdminWorkerNotificationsErrorsParamsNotificationTypeDailyReminder GetV1AdminWorkerNotificationsErrorsParamsNotificationType = "daily_reminder"
112
    GetV1AdminWorkerNotificationsErrorsParamsNotificationTypeTestEmail     GetV1AdminWorkerNotificationsErrorsParamsNotificationType = "test_email"
113
)
114

115
// Defines values for GetV1AdminWorkerNotificationsErrorsParamsResolved.
116
const (
117
    False GetV1AdminWorkerNotificationsErrorsParamsResolved = "false"
118
    True  GetV1AdminWorkerNotificationsErrorsParamsResolved = "true"
119
)
120

121
// Defines values for GetV1AdminWorkerNotificationsSentParamsNotificationType.
122
const (
123
    GetV1AdminWorkerNotificationsSentParamsNotificationTypeDailyReminder GetV1AdminWorkerNotificationsSentParamsNotificationType = "daily_reminder"
124
    GetV1AdminWorkerNotificationsSentParamsNotificationTypeTestEmail     GetV1AdminWorkerNotificationsSentParamsNotificationType = "test_email"
125
)
126

127
// Defines values for GetV1AdminWorkerNotificationsSentParamsStatus.
128
const (
129
    GetV1AdminWorkerNotificationsSentParamsStatusBounced GetV1AdminWorkerNotificationsSentParamsStatus = "bounced"
130
    GetV1AdminWorkerNotificationsSentParamsStatusFailed  GetV1AdminWorkerNotificationsSentParamsStatus = "failed"
131
    GetV1AdminWorkerNotificationsSentParamsStatusSent    GetV1AdminWorkerNotificationsSentParamsStatus = "sent"
132
)
133

134
// AIConcurrencyStats defines model for AIConcurrencyStats.
135
type AIConcurrencyStats struct {
136
    ActiveRequests  *int            `json:"active_requests,omitempty"`
137
    MaxConcurrent   *int            `json:"max_concurrent,omitempty"`
138
    MaxPerUser      *int            `json:"max_per_user,omitempty"`
139
    QueuedRequests  *int            `json:"queued_requests,omitempty"`
140
    TotalRequests   *int            `json:"total_requests,omitempty"`
141
    UserActiveCount *map[string]int `json:"user_active_count,omitempty"`
142
}
143

144
// AIProviders defines model for AIProviders.
145
type AIProviders struct {
146
    Levels    *[]string `json:"levels,omitempty"`
147
    Providers *[]struct {
148
        Code   *string `json:"code,omitempty"`
149
        Models *[]struct {
150
            Code *string `json:"code,omitempty"`
151
            Name *string `json:"name,omitempty"`
152
        } `json:"models,omitempty"`
153
        Name *string `json:"name,omitempty"`
154
        Url  *string `json:"url,omitempty"`
155
    } `json:"providers,omitempty"`
156
}
157

158
// APIKeyAvailabilityResponse defines model for APIKeyAvailabilityResponse.
159
type APIKeyAvailabilityResponse struct {
160
    // HasApiKey Whether the user has a saved API key for this provider
161
    HasApiKey bool `json:"has_api_key"`
162
}
163

164
// AggregatedVersion defines model for AggregatedVersion.
165
type AggregatedVersion struct {
166
    Backend ServiceVersion           `json:"backend"`
167
    Worker  AggregatedVersion_Worker `json:"worker"`
168
}
169

170
// AggregatedVersionWorker1 defines model for .
171
type AggregatedVersionWorker1 struct {
172
    // Error Error message when worker is unavailable
173
    Error string `json:"error"`
174
}
175

176
// AggregatedVersion_Worker defines model for AggregatedVersion.Worker.
177
type AggregatedVersion_Worker struct {
178
    union json.RawMessage
179
}
180

181
// AnswerRequest defines model for AnswerRequest.
182
type AnswerRequest struct {
183
    // QuestionId ID of the question being answered
184
    QuestionId int64 `json:"question_id"`
185

186
    // ResponseTimeMs Response time in milliseconds (0-5 minutes)
187
    ResponseTimeMs *int32 `json:"response_time_ms,omitempty"`
188

189
    // UserAnswerIndex Index of the user's selected answer in the original options array (0-based)
190
    UserAnswerIndex int `json:"user_answer_index"`
191
}
192

193
// AnswerResponse defines model for AnswerResponse.
194
type AnswerResponse struct {
195
    // CorrectAnswerIndex Index of the correct answer in the options array (0-based)
196
    CorrectAnswerIndex *int    `json:"correct_answer_index,omitempty"`
197
    Explanation        *string `json:"explanation,omitempty"`
198
    IsCorrect          *bool   `json:"is_correct,omitempty"`
199
    NextDifficulty     *string `json:"next_difficulty,omitempty"`
200

201
    // UserAnswer The answer selected by the user
202
    UserAnswer *string `json:"user_answer,omitempty"`
203

204
    // UserAnswerIndex Index of the user's selected answer in the original options array (0-based)
205
    UserAnswerIndex *int `json:"user_answer_index,omitempty"`
206
}
207

208
// AuthStatusResponse defines model for AuthStatusResponse.
209
type AuthStatusResponse struct {
210
    // Authenticated Whether the user is currently authenticated
211
    Authenticated bool `json:"authenticated"`
212
    User          User `json:"user"`
213
}
214

215
// ChatMessage defines model for ChatMessage.
216
type ChatMessage struct {
217
    // Content The message content
218
    Content string `json:"content"`
219

220
    // Role The role of the message sender
221
    Role ChatMessageRole `json:"role"`
222
}
223

224
// ChatMessageRole The role of the message sender
225
type ChatMessageRole string
226

227
// DailyProgress defines model for DailyProgress.
228
type DailyProgress struct {
229
    // Completed Number of completed questions
230
    Completed int `json:"completed"`
231

232
    // Date Date for the progress report (YYYY-MM-DD)
233
    Date openapi_types.Date `json:"date"`
234

235
    // Total Total number of questions assigned for the date
236
    Total int `json:"total"`
237
}
238

239
// DailyQuestionHistory defines model for DailyQuestionHistory.
240
type DailyQuestionHistory struct {
241
    // AssignmentDate RFC3339 timestamp of when the question was assigned in the user's timezone (includes offset)
242
    AssignmentDate string `json:"assignment_date"`
243

244
    // IsCompleted Whether the question was completed on this date
245
    IsCompleted bool `json:"is_completed"`
246

247
    // IsCorrect Whether the user's answer was correct (null if not attempted)
248
    IsCorrect *bool `json:"is_correct"`
249

250
    // SubmittedAt When the user submitted their answer
251
    SubmittedAt *string `json:"submitted_at"`
252
}
253

254
// DailyQuestionWithDetails defines model for DailyQuestionWithDetails.
255
type DailyQuestionWithDetails struct {
256
    // AssignmentDate Date-only assignment (YYYY-MM-DD) representing the logical calendar day the question was assigned (no timezone offset)
257
    AssignmentDate openapi_types.Date `json:"assignment_date"`
258

259
    // CompletedAt When the question was completed (if completed)
260
    CompletedAt *string `json:"completed_at"`
261

262
    // CreatedAt When the assignment was created
263
    CreatedAt string `json:"created_at"`
264

265
    // Id Daily question assignment ID
266
    Id int64 `json:"id"`
267

268
    // IsCompleted Whether the question has been completed
269
    IsCompleted bool     `json:"is_completed"`
270
    Question    Question `json:"question"`
271

272
    // QuestionId Question ID
273
    QuestionId int64 `json:"question_id"`
274

275
    // SubmittedAt When the user submitted their answer
276
    SubmittedAt *string `json:"submitted_at"`
277

278
    // UserAnswerIndex The index of the answer option the user selected (0-based)
279
    UserAnswerIndex *int `json:"user_answer_index"`
280

281
    // UserCorrectCount Number of times this user answered this question correctly
282
    UserCorrectCount *int64 `json:"user_correct_count,omitempty"`
283

284
    // UserId User ID
285
    UserId int64 `json:"user_id"`
286

287
    // UserIncorrectCount Number of times this user answered this question incorrectly
288
    UserIncorrectCount *int64 `json:"user_incorrect_count,omitempty"`
289

290
    // UserShownCount Number of times this question was shown to this user in Daily view
291
    UserShownCount *int64 `json:"user_shown_count,omitempty"`
292

293
    // UserTotalResponses Number of times this user answered this question
294
    UserTotalResponses *int64 `json:"user_total_responses,omitempty"`
295
}
296

297
// DashboardResponse defines model for DashboardResponse.
298
type DashboardResponse struct {
299
    AiConcurrencyStats *AIConcurrencyStats `json:"ai_concurrency_stats,omitempty"`
300
    QuestionStats      *QuestionStats      `json:"question_stats,omitempty"`
301
    Users              *[]DashboardUser    `json:"users,omitempty"`
302
    WorkerBaseUrl      *string             `json:"worker_base_url,omitempty"`
303
    WorkerHealth       *WorkerHealth       `json:"worker_health,omitempty"`
304
    WorkerPort         *string             `json:"worker_port,omitempty"`
305
}
306

307
// DashboardUser defines model for DashboardUser.
308
type DashboardUser struct {
309
    Progress      *UserProgress      `json:"progress,omitempty"`
310
    QuestionStats *UserQuestionStats `json:"question_stats,omitempty"`
311
    User          *UserProfile       `json:"user,omitempty"`
312
}
313

314
// ErrorResponse defines model for ErrorResponse.
315
type ErrorResponse struct {
316
    Details *string `json:"details,omitempty"`
317
    Error   *string `json:"error,omitempty"`
318
}
319

320
// ForceSendNotificationResponse defines model for ForceSendNotificationResponse.
321
type ForceSendNotificationResponse struct {
322
    Message      *string `json:"message,omitempty"`
323
    Notification *struct {
324
        Status  *string `json:"status,omitempty"`
325
        Subject *string `json:"subject,omitempty"`
326
        Type    *string `json:"type,omitempty"`
327
    } `json:"notification,omitempty"`
328
    User *struct {
329
        Email    *string `json:"email,omitempty"`
330
        Id       *int64  `json:"id,omitempty"`
331
        Username *string `json:"username,omitempty"`
332
    } `json:"user,omitempty"`
333
}
334

335
// GeneratingResponse defines model for GeneratingResponse.
336
type GeneratingResponse struct {
337
    // AiModel User's preferred AI model
338
    AiModel *string `json:"ai_model,omitempty"`
339

340
    // ApiKey User's API key for the selected provider (write-only)
341
    ApiKey  *string `json:"api_key,omitempty"`
342
    Message *string `json:"message,omitempty"`
343
    Status  *string `json:"status,omitempty"`
344
}
345

346
// GenerationFocus defines model for GenerationFocus.
347
type GenerationFocus struct {
348
    // CurrentGenerationModel The AI model currently being used for generation
349
    CurrentGenerationModel *string `json:"current_generation_model,omitempty"`
350

351
    // GenerationRate Average number of questions generated per minute
352
    GenerationRate *float32 `json:"generation_rate,omitempty"`
353

354
    // LastGenerationTime Timestamp of the last time a question was generated
355
    LastGenerationTime *string `json:"last_generation_time,omitempty"`
356
}
357

358
// GenerationIntelligence defines model for GenerationIntelligence.
359
type GenerationIntelligence struct {
360
    GapAnalysis           *[]map[string]interface{} `json:"gapAnalysis,omitempty"`
361
    GenerationSuggestions *[]map[string]interface{} `json:"generationSuggestions,omitempty"`
362
}
363

364
// GoogleOAuthLoginResponse defines model for GoogleOAuthLoginResponse.
365
type GoogleOAuthLoginResponse struct {
366
    // AuthUrl The Google OAuth authorization URL to redirect the user to
367
    AuthUrl string `json:"auth_url"`
368
}
369

370
// Language Learning language (dynamic). Allowed values come from config.yaml language_levels keys.
371
type Language = string
372

373
// LanguagesResponse Array of available learning languages
374
type LanguagesResponse = []string
375

376
// Level Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
377
type Level = string
378

379
// LevelsResponse defines model for LevelsResponse.
380
type LevelsResponse struct {
381
    // LevelDescriptions Mapping from level code to short label (e.g. Beginner, Intermediate)
382
    LevelDescriptions map[string]string `json:"level_descriptions"`
383

384
    // Levels Array of available language proficiency levels
385
    Levels []string `json:"levels"`
386
}
387

388
// LoginRequest defines model for LoginRequest.
389
type LoginRequest struct {
390
    // Password Password (minimum 8 characters)
391
    Password string `json:"password"`
392

393
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
394
    Username string `json:"username"`
395
}
396

397
// LoginResponse defines model for LoginResponse.
398
type LoginResponse struct {
399
    Message *string `json:"message,omitempty"`
400

401
    // RedirectUri Redirect URI for OAuth flows (optional)
402
    RedirectUri *string `json:"redirect_uri,omitempty"`
403
    Success     *bool   `json:"success,omitempty"`
404
    User        *User   `json:"user,omitempty"`
405
}
406

407
// NotificationError defines model for NotificationError.
408
type NotificationError struct {
409
    // EmailAddress Email address that was being used
410
    EmailAddress *string `json:"email_address"`
411

412
    // ErrorMessage Detailed error message
413
    ErrorMessage *string `json:"error_message,omitempty"`
414

415
    // ErrorType Type of error that occurred
416
    ErrorType *NotificationErrorErrorType `json:"error_type,omitempty"`
417
    Id        *int64                      `json:"id,omitempty"`
418

419
    // NotificationType Type of notification that failed
420
    NotificationType *NotificationErrorNotificationType `json:"notification_type,omitempty"`
421

422
    // OccurredAt When the error occurred
423
    OccurredAt *string `json:"occurred_at,omitempty"`
424

425
    // ResolutionNotes Notes about how the error was resolved
426
    ResolutionNotes *string `json:"resolution_notes"`
427

428
    // ResolvedAt When the error was resolved
429
    ResolvedAt *string `json:"resolved_at"`
430
    UserId     *int64  `json:"user_id"`
431

432
    // Username Username of the user (if available)
433
    Username *string `json:"username,omitempty"`
434
}
435

436
// NotificationErrorErrorType Type of error that occurred
437
type NotificationErrorErrorType string
438

439
// NotificationErrorNotificationType Type of notification that failed
440
type NotificationErrorNotificationType string
441

442
// NotificationErrorStats defines model for NotificationErrorStats.
443
type NotificationErrorStats struct {
444
    // ErrorsByNotificationType Breakdown of errors by notification type
445
    ErrorsByNotificationType *map[string]int `json:"errors_by_notification_type,omitempty"`
446

447
    // ErrorsByType Breakdown of errors by type
448
    ErrorsByType *map[string]int `json:"errors_by_type,omitempty"`
449

450
    // TotalErrors Total number of errors
451
    TotalErrors *int `json:"total_errors,omitempty"`
452

453
    // UnresolvedErrors Number of unresolved errors
454
    UnresolvedErrors *int `json:"unresolved_errors,omitempty"`
455
}
456

457
// NotificationStats defines model for NotificationStats.
458
type NotificationStats struct {
459
    // NotificationsByType Breakdown of notifications by type
460
    NotificationsByType *map[string]int `json:"notifications_by_type,omitempty"`
461

462
    // SentThisWeek Number of notifications sent this week
463
    SentThisWeek *int `json:"sent_this_week,omitempty"`
464

465
    // SentToday Number of notifications sent today
466
    SentToday *int `json:"sent_today,omitempty"`
467

468
    // SuccessRate Success rate as a percentage (0-1)
469
    SuccessRate *float32 `json:"success_rate,omitempty"`
470

471
    // TotalFailed Total number of notifications that failed
472
    TotalFailed *int `json:"total_failed,omitempty"`
473

474
    // TotalSent Total number of notifications sent
475
    TotalSent *int `json:"total_sent,omitempty"`
476
}
477

478
// PaginationInfo defines model for PaginationInfo.
479
type PaginationInfo struct {
480
    // Page Current page number
481
    Page int `json:"page"`
482

483
    // PageSize Number of items per page
484
    PageSize int `json:"page_size"`
485

486
    // Total Total number of items
487
    Total int `json:"total"`
488

489
    // TotalPages Total number of pages
490
    TotalPages int `json:"total_pages"`
491
}
492

493
// PasswordResetRequest defines model for PasswordResetRequest.
494
type PasswordResetRequest struct {
495
    // NewPassword New password (minimum 8 characters)
496
    NewPassword string `json:"new_password"`
497
}
498

499
// PerformanceMetrics defines model for PerformanceMetrics.
500
type PerformanceMetrics struct {
501
    AverageResponseTimeMs *float32 `json:"average_response_time_ms,omitempty"`
502
    CorrectAttempts       *int     `json:"correct_attempts,omitempty"`
503
    LastUpdated           *string  `json:"last_updated,omitempty"`
504
    TotalAttempts         *int     `json:"total_attempts,omitempty"`
505
}
506

507
// PriorityInsights defines model for PriorityInsights.
508
type PriorityInsights struct {
509
    // HighPriorityQuestions Number of high-priority questions
510
    HighPriorityQuestions *int `json:"high_priority_questions,omitempty"`
511

512
    // LowPriorityQuestions Number of low-priority questions
513
    LowPriorityQuestions *int `json:"low_priority_questions,omitempty"`
514

515
    // MediumPriorityQuestions Number of medium-priority questions
516
    MediumPriorityQuestions *int `json:"medium_priority_questions,omitempty"`
517

518
    // TotalQuestionsInQueue Total number of questions waiting to be processed
519
    TotalQuestionsInQueue *int `json:"total_questions_in_queue,omitempty"`
520
}
521

522
// Question defines model for Question.
523
type Question struct {
524
    // ConfidenceLevel Confidence level when question was marked as known (1-5)
525
    ConfidenceLevel *int `json:"confidence_level,omitempty"`
526

527
    // Content All question types now use multiple choice format with 4 options
528
    Content *QuestionContent `json:"content,omitempty"`
529

530
    // CorrectAnswer Index of the correct answer in the options array (0-based)
531
    CorrectAnswer *int `json:"correct_answer,omitempty"`
532

533
    // CorrectCount Number of times this question was answered correctly
534
    CorrectCount *int    `json:"correct_count,omitempty"`
535
    CreatedAt    *string `json:"created_at,omitempty"`
536

537
    // DifficultyModifier Difficulty modifier for the question (e.g., basic, intermediate)
538
    DifficultyModifier *string  `json:"difficulty_modifier,omitempty"`
539
    DifficultyScore    *float32 `json:"difficulty_score,omitempty"`
540
    Explanation        *string  `json:"explanation,omitempty"`
541

542
    // GrammarFocus Grammar focus area for the question (e.g., present_perfect, conditionals)
543
    GrammarFocus *string `json:"grammar_focus,omitempty"`
544
    Id           *int64  `json:"id,omitempty"`
545

546
    // IncorrectCount Number of times this question was answered incorrectly
547
    IncorrectCount *int `json:"incorrect_count,omitempty"`
548

549
    // Language Learning language (dynamic). Allowed values come from config.yaml language_levels keys.
550
    Language *Language `json:"language,omitempty"`
551

552
    // Level Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
553
    Level *Level `json:"level,omitempty"`
554

555
    // Reporters Comma-separated list of usernames who reported this question
556
    Reporters *string `json:"reporters,omitempty"`
557

558
    // Scenario Scenario context for the question (e.g., at_the_airport, in_a_restaurant)
559
    Scenario *string         `json:"scenario,omitempty"`
560
    Status   *QuestionStatus `json:"status,omitempty"`
561

562
    // StyleModifier Style modifier for the question (e.g., conversational, formal)
563
    StyleModifier *string `json:"style_modifier,omitempty"`
564

565
    // TimeContext Time context for the question (e.g., morning_routine, workday)
566
    TimeContext *string `json:"time_context,omitempty"`
567

568
    // TopicCategory General topic category for question context (e.g., daily_life, travel, work)
569
    TopicCategory *string `json:"topic_category,omitempty"`
570

571
    // TotalResponses Total number of responses to this question (used for 'Shown' in the UI)
572
    TotalResponses *int          `json:"total_responses,omitempty"`
573
    Type           *QuestionType `json:"type,omitempty"`
574

575
    // UserCount Number of users assigned to this question
576
    UserCount *int `json:"user_count,omitempty"`
577

578
    // VocabularyDomain Vocabulary domain for the question (e.g., food_and_dining, transportation)
579
    VocabularyDomain *string `json:"vocabulary_domain,omitempty"`
580
}
581

582
// QuestionContent All question types now use multiple choice format with 4 options
583
type QuestionContent struct {
584
    // Hint Optional hint for fill-in-blank questions
585
    Hint    *string  `json:"hint,omitempty"`
586
    Options []string `json:"options"`
587

588
    // Passage Only present for reading comprehension questions
589
    Passage  *string `json:"passage,omitempty"`
590
    Question string  `json:"question"`
591

592
    // Sentence Only present for vocabulary questions (context sentence)
593
    Sentence *string `json:"sentence,omitempty"`
594
}
595

596
// QuestionStats defines model for QuestionStats.
597
type QuestionStats struct {
598
    // QuestionsByLanguage Breakdown of questions by language
599
    QuestionsByLanguage *map[string]int `json:"questions_by_language,omitempty"`
600

601
    // QuestionsByLevel Breakdown of questions by level
602
    QuestionsByLevel *map[string]int `json:"questions_by_level,omitempty"`
603

604
    // QuestionsByType Breakdown of questions by type
605
    QuestionsByType *map[string]int `json:"questions_by_type,omitempty"`
606

607
    // TotalQuestions Total number of questions
608
    TotalQuestions *int `json:"total_questions,omitempty"`
609

610
    // TotalResponses Total number of responses
611
    TotalResponses *int `json:"total_responses,omitempty"`
612
}
613

614
// QuestionStatus defines model for QuestionStatus.
615
type QuestionStatus string
616

617
// QuestionType defines model for QuestionType.
618
type QuestionType string
619

620
// QuizChatRequest defines model for QuizChatRequest.
621
type QuizChatRequest struct {
622
    AnswerContext *AnswerResponse `json:"answer_context,omitempty"`
623

624
    // ConversationHistory Previous messages in the conversation
625
    ConversationHistory *[]ChatMessage `json:"conversation_history,omitempty"`
626
    Question            Question       `json:"question"`
627

628
    // UserMessage The user's message to the AI tutor.
629
    UserMessage string `json:"user_message"`
630
}
631

632
// Role defines model for Role.
633
type Role struct {
634
    // CreatedAt When the role was created
635
    CreatedAt string `json:"created_at"`
636

637
    // Description Role description
638
    Description string `json:"description"`
639

640
    // Id Role ID
641
    Id int64 `json:"id"`
642

643
    // Name Role name (e.g., "user", "admin")
644
    Name string `json:"name"`
645

646
    // UpdatedAt When the role was last updated
647
    UpdatedAt string `json:"updated_at"`
648
}
649

650
// SentNotification defines model for SentNotification.
651
type SentNotification struct {
652
    // EmailAddress Email address the notification was sent to
653
    EmailAddress *string `json:"email_address,omitempty"`
654

655
    // ErrorMessage Error message if the notification failed
656
    ErrorMessage *string `json:"error_message"`
657
    Id           *int64  `json:"id,omitempty"`
658

659
    // NotificationType Type of notification
660
    NotificationType *SentNotificationNotificationType `json:"notification_type,omitempty"`
661

662
    // RetryCount Number of times the notification was retried
663
    RetryCount *int `json:"retry_count,omitempty"`
664

665
    // SentAt When the notification was sent
666
    SentAt *string `json:"sent_at,omitempty"`
667

668
    // Status Status of the notification
669
    Status *SentNotificationStatus `json:"status,omitempty"`
670

671
    // Subject Subject line of the email
672
    Subject *string `json:"subject,omitempty"`
673

674
    // TemplateName Template used for the notification
675
    TemplateName *string `json:"template_name,omitempty"`
676
    UserId       *int64  `json:"user_id,omitempty"`
677

678
    // Username Username of the user
679
    Username *string `json:"username,omitempty"`
680
}
681

682
// SentNotificationNotificationType Type of notification
683
type SentNotificationNotificationType string
684

685
// SentNotificationStatus Status of the notification
686
type SentNotificationStatus string
687

688
// ServiceVersion defines model for ServiceVersion.
689
type ServiceVersion struct {
690
    // BuildTime Build timestamp (ISO8601)
691
    BuildTime string `json:"buildTime"`
692

693
    // Commit Git commit hash
694
    Commit string `json:"commit"`
695

696
    // Service Service name (e.g., 'backend', 'worker')
697
    Service string `json:"service"`
698

699
    // Version Version string (e.g., git tag or 'dev')
700
    Version string `json:"version"`
701
}
702

703
// SuccessResponse defines model for SuccessResponse.
704
type SuccessResponse struct {
705
    Message *string `json:"message,omitempty"`
706
    Success bool    `json:"success"`
707
}
708

709
// SystemHealthAnalytics defines model for SystemHealthAnalytics.
710
type SystemHealthAnalytics struct {
711
    BackgroundJobs *map[string]interface{} `json:"backgroundJobs,omitempty"`
712
    Performance    *map[string]interface{} `json:"performance,omitempty"`
713
}
714

715
// TTSRequest defines model for TTSRequest.
716
type TTSRequest struct {
717
    // Input The text to convert to speech
718
    Input string `json:"input"`
719

720
    // Model The TTS model to use
721
    Model *string `json:"model,omitempty"`
722

723
    // StreamFormat The format for streaming audio data
724
    StreamFormat *TTSRequestStreamFormat `json:"stream_format,omitempty"`
725

726
    // Voice The voice to use for speech generation
727
    Voice *string `json:"voice,omitempty"`
728
}
729

730
// TTSRequestStreamFormat The format for streaming audio data
731
type TTSRequestStreamFormat string
732

733
// TTSResponse defines model for TTSResponse.
734
type TTSResponse struct {
735
    // Audio Base64 encoded audio chunk (for type=audio)
736
    Audio *string `json:"audio,omitempty"`
737

738
    // Error Error message (for type=error)
739
    Error *string `json:"error,omitempty"`
740

741
    // Type The type of SSE event
742
    Type *TTSResponseType `json:"type,omitempty"`
743

744
    // Usage Usage statistics (for type=usage)
745
    Usage *struct {
746
        // InputTokens Number of input tokens processed
747
        InputTokens *int `json:"input_tokens,omitempty"`
748

749
        // OutputTokens Number of output tokens generated
750
        OutputTokens *int `json:"output_tokens,omitempty"`
751

752
        // TotalTokens Total tokens used
753
        TotalTokens *int `json:"total_tokens,omitempty"`
754
    } `json:"usage,omitempty"`
755
}
756

757
// TTSResponseType The type of SSE event
758
type TTSResponseType string
759

760
// TestAIRequest defines model for TestAIRequest.
761
type TestAIRequest struct {
762
    // ApiKey API key for the provider. If not provided, the server will try to use a saved key.
763
    ApiKey *string `json:"api_key"`
764

765
    // Model AI model code (e.g., "llama3", "gpt-4")
766
    Model string `json:"model"`
767

768
    // Provider AI provider code (e.g., "ollama", "openai")
769
    Provider string `json:"provider"`
770
}
771

772
// User defines model for User.
773
type User struct {
774
    // AiEnabled Whether AI features are enabled for this user
775
    AiEnabled    *bool   `json:"ai_enabled"`
776
    AiModel      *string `json:"ai_model"`
777
    AiProvider   *string `json:"ai_provider"`
778
    CreatedAt    *string `json:"created_at,omitempty"`
779
    CurrentLevel *string `json:"current_level"`
780
    Email        *string `json:"email"`
781

782
    // HasApiKey Whether the user has a valid API key saved for their current AI provider
783
    HasApiKey *bool  `json:"has_api_key,omitempty"`
784
    Id        *int64 `json:"id,omitempty"`
785

786
    // IsPaused Whether the user is paused (question generation disabled)
787
    IsPaused          *bool   `json:"is_paused,omitempty"`
788
    LastActive        *string `json:"last_active"`
789
    PreferredLanguage *string `json:"preferred_language"`
790

791
    // Roles List of roles assigned to the user
792
    Roles    *[]Role `json:"roles,omitempty"`
793
    Timezone *string `json:"timezone"`
794

795
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
796
    Username *string `json:"username,omitempty"`
797
}
798

799
// UserCreateRequest defines model for UserCreateRequest.
800
type UserCreateRequest struct {
801
    // AiEnabled Whether AI features are enabled for this user
802
    AiEnabled *bool `json:"ai_enabled,omitempty"`
803

804
    // CurrentLevel Current proficiency level
805
    CurrentLevel *string `json:"current_level,omitempty"`
806

807
    // Email Email address
808
    Email *openapi_types.Email `json:"email,omitempty"`
809

810
    // Password Password (minimum 8 characters)
811
    Password string `json:"password"`
812

813
    // PreferredLanguage Preferred learning language
814
    PreferredLanguage *string `json:"preferred_language,omitempty"`
815

816
    // Timezone Timezone (e.g., "UTC", "America/New_York")
817
    Timezone *string `json:"timezone,omitempty"`
818

819
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
820
    Username string `json:"username"`
821
}
822

823
// UserLearningPreferences defines model for UserLearningPreferences.
824
type UserLearningPreferences struct {
825
    // DailyGoal User-configurable number of daily questions
826
    DailyGoal *int `json:"daily_goal,omitempty"`
827

828
    // DailyReminderEnabled Whether to receive daily reminder emails
829
    DailyReminderEnabled bool `json:"daily_reminder_enabled"`
830

831
    // FocusOnWeakAreas Whether to focus on weak areas
832
    FocusOnWeakAreas bool `json:"focus_on_weak_areas"`
833

834
    // FreshQuestionRatio Ratio of fresh (never seen) questions to show (0-1)
835
    FreshQuestionRatio float32 `json:"fresh_question_ratio"`
836

837
    // KnownQuestionPenalty Penalty multiplier for questions marked as known (0-1)
838
    KnownQuestionPenalty float32 `json:"known_question_penalty"`
839

840
    // ReviewIntervalDays Days between reviews of known questions
841
    ReviewIntervalDays int `json:"review_interval_days"`
842

843
    // TtsVoice Preferred TTS voice (e.g., it-IT-IsabellaNeural)
844
    TtsVoice *string `json:"tts_voice,omitempty"`
845

846
    // WeakAreaBoost Multiplier for weak area questions
847
    WeakAreaBoost float32 `json:"weak_area_boost"`
848
}
849

850
// UserPerformanceAnalytics defines model for UserPerformanceAnalytics.
851
type UserPerformanceAnalytics struct {
852
    LearningPreferences *map[string]interface{}   `json:"learningPreferences,omitempty"`
853
    WeakAreas           *[]map[string]interface{} `json:"weakAreas,omitempty"`
854
}
855

856
// UserProfile defines model for UserProfile.
857
type UserProfile struct {
858
    // AiEnabled Whether AI features are enabled for this user
859
    AiEnabled    *bool   `json:"ai_enabled"`
860
    CreatedAt    *string `json:"created_at,omitempty"`
861
    CurrentLevel *string `json:"current_level,omitempty"`
862
    Email        *string `json:"email"`
863
    Id           *int64  `json:"id,omitempty"`
864

865
    // IsPaused Whether the user is paused (question generation disabled)
866
    IsPaused          *bool   `json:"is_paused,omitempty"`
867
    LastActive        *string `json:"last_active"`
868
    PreferredLanguage *string `json:"preferred_language"`
869
    Timezone          *string `json:"timezone"`
870
    UpdatedAt         *string `json:"updated_at,omitempty"`
871

872
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
873
    Username *string `json:"username,omitempty"`
874
}
875

876
// UserProgress defines model for UserProgress.
877
type UserProgress struct {
878
    AccuracyRate   *float32 `json:"accuracy_rate,omitempty"`
879
    CorrectAnswers *int     `json:"correct_answers,omitempty"`
880

881
    // CurrentLevel Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
882
    CurrentLevel *Level `json:"current_level,omitempty"`
883

884
    // GapAnalysis Analysis of learning gaps and areas needing attention
885
    GapAnalysis     *map[string]interface{} `json:"gap_analysis,omitempty"`
886
    GenerationFocus *GenerationFocus        `json:"generation_focus,omitempty"`
887

888
    // HighPriorityTopics Topics that have high priority scores for the user
889
    HighPriorityTopics  *[]string                      `json:"high_priority_topics,omitempty"`
890
    LearningPreferences *UserLearningPreferences       `json:"learning_preferences,omitempty"`
891
    PerformanceByTopic  *map[string]PerformanceMetrics `json:"performance_by_topic,omitempty"`
892

893
    // PriorityDistribution Distribution of question priorities (high, medium, low counts)
894
    PriorityDistribution *map[string]int   `json:"priority_distribution,omitempty"`
895
    PriorityInsights     *PriorityInsights `json:"priority_insights,omitempty"`
896
    RecentActivity       *[]UserResponse   `json:"recent_activity,omitempty"`
897

898
    // SuggestedLevel Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
899
    SuggestedLevel *Level        `json:"suggested_level,omitempty"`
900
    TotalQuestions *int          `json:"total_questions,omitempty"`
901
    WeakAreas      *[]string     `json:"weak_areas,omitempty"`
902
    WorkerStatus   *WorkerStatus `json:"worker_status,omitempty"`
903
}
904

905
// UserQuestionStats defines model for UserQuestionStats.
906
type UserQuestionStats struct {
907
    AccuracyByLevel  *map[string]float32 `json:"accuracy_by_level,omitempty"`
908
    AccuracyByType   *map[string]float32 `json:"accuracy_by_type,omitempty"`
909
    AnsweredByLevel  *map[string]int     `json:"answered_by_level,omitempty"`
910
    AnsweredByType   *map[string]int     `json:"answered_by_type,omitempty"`
911
    AvailableByLevel *map[string]int     `json:"available_by_level,omitempty"`
912
    AvailableByType  *map[string]int     `json:"available_by_type,omitempty"`
913
    TotalAnswered    *int                `json:"total_answered,omitempty"`
914
    UserId           *int64              `json:"user_id,omitempty"`
915
}
916

917
// UserResponse defines model for UserResponse.
918
type UserResponse struct {
919
    CreatedAt  *string `json:"created_at,omitempty"`
920
    IsCorrect  *bool   `json:"is_correct,omitempty"`
921
    QuestionId *int64  `json:"question_id,omitempty"`
922
}
923

924
// UserSettings defines model for UserSettings.
925
type UserSettings struct {
926
    // AiEnabled Whether AI features are enabled for this user
927
    AiEnabled  *bool   `json:"ai_enabled,omitempty"`
928
    AiModel    *string `json:"ai_model,omitempty"`
929
    AiProvider *string `json:"ai_provider,omitempty"`
930

931
    // ApiKey API key for AI provider (write-only)
932
    ApiKey *string `json:"api_key,omitempty"`
933

934
    // Language Learning language (dynamic). Allowed values come from config.yaml language_levels keys.
935
    Language *Language `json:"language,omitempty"`
936

937
    // Level Proficiency level (dynamic). Allowed values depend on the selected language and are sourced from config.yaml (e.g., CEFR A1âC2, JLPT N5âN1, HSK1âHSK6).
938
    Level *Level `json:"level,omitempty"`
939
    union json.RawMessage
940
}
941

942
// UserSettings0 defines model for .
943
type UserSettings0 = interface{}
944

945
// UserSettings1 defines model for .
946
type UserSettings1 = interface{}
947

948
// UserUpdateRequest defines model for UserUpdateRequest.
949
type UserUpdateRequest struct {
950
    // AiEnabled Whether AI features are enabled for this user
951
    AiEnabled *bool `json:"ai_enabled,omitempty"`
952

953
    // AiModel AI model code
954
    AiModel *string `json:"ai_model,omitempty"`
955

956
    // AiProvider AI provider code
957
    AiProvider *string `json:"ai_provider,omitempty"`
958

959
    // ApiKey API key for AI provider (write-only)
960
    ApiKey *string `json:"api_key,omitempty"`
961

962
    // CurrentLevel Current proficiency level
963
    CurrentLevel *string `json:"current_level,omitempty"`
964

965
    // Email Email address
966
    Email *openapi_types.Email `json:"email,omitempty"`
967

968
    // PreferredLanguage Preferred learning language
969
    PreferredLanguage *string `json:"preferred_language,omitempty"`
970

971
    // SelectedRoles Array of role names to assign to the user
972
    SelectedRoles *[]string `json:"selectedRoles,omitempty"`
973

974
    // Timezone Timezone (e.g., "UTC", "America/New_York")
975
    Timezone *string `json:"timezone,omitempty"`
976

977
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
978
    Username *string `json:"username,omitempty"`
979
    union    json.RawMessage
980
}
981

982
// UserUpdateRequest0 defines model for .
983
type UserUpdateRequest0 = interface{}
984

985
// UserUpdateRequest1 defines model for .
986
type UserUpdateRequest1 = interface{}
987

988
// WorkerHealth defines model for WorkerHealth.
989
type WorkerHealth struct {
990
    GlobalPaused    *bool `json:"global_paused,omitempty"`
991
    HealthyCount    *int  `json:"healthy_count,omitempty"`
992
    TotalCount      *int  `json:"total_count,omitempty"`
993
    WorkerInstances *[]struct {
994
        Healthy       *bool `json:"healthy,omitempty"`
995
        IsPaused      *bool `json:"is_paused,omitempty"`
996
        IsRunning     *bool `json:"is_running,omitempty"`
997
        LastHeartbeat *struct {
998
            Time  *string `json:"Time,omitempty"`
999
            Valid *bool   `json:"Valid,omitempty"`
1000
        } `json:"last_heartbeat,omitempty"`
1001
        TotalQuestionsGenerated *int    `json:"total_questions_generated,omitempty"`
1002
        TotalRuns               *int    `json:"total_runs,omitempty"`
1003
        WorkerInstance          *string `json:"worker_instance,omitempty"`
1004
    } `json:"worker_instances,omitempty"`
1005
}
1006

1007
// WorkerStatus defines model for WorkerStatus.
1008
type WorkerStatus struct {
1009
    // ErrorMessage Error message if the worker is in an error state
1010
    ErrorMessage *string `json:"error_message"`
1011

1012
    // LastHeartbeat Timestamp of the last heartbeat from the worker
1013
    LastHeartbeat *string `json:"last_heartbeat,omitempty"`
1014

1015
    // Status Current status of the worker
1016
    Status *WorkerStatusStatus `json:"status,omitempty"`
1017
}
1018

1019
// WorkerStatusStatus Current status of the worker
1020
type WorkerStatusStatus string
1021

1022
// WorkerStatusResponse defines model for WorkerStatusResponse.
1023
type WorkerStatusResponse struct {
1024
    // ErrorMessage Error message if worker has errors
1025
    ErrorMessage string `json:"error_message"`
1026

1027
    // GlobalPaused Whether the worker is globally paused
1028
    GlobalPaused bool `json:"global_paused"`
1029

1030
    // HasErrors Whether the worker has encountered errors
1031
    HasErrors bool `json:"has_errors"`
1032

1033
    // HealthyWorkers Number of healthy worker instances
1034
    HealthyWorkers int `json:"healthy_workers"`
1035

1036
    // LastErrorDetails Detailed error information if any
1037
    LastErrorDetails string `json:"last_error_details"`
1038

1039
    // TotalWorkers Total number of worker instances
1040
    TotalWorkers int `json:"total_workers"`
1041

1042
    // UserPaused Whether the user's question generation is paused
1043
    UserPaused bool `json:"user_paused"`
1044

1045
    // WorkerRunning Whether the worker is currently running
1046
    WorkerRunning bool `json:"worker_running"`
1047
}
1048

1049
// GetV1AdminBackendQuestionsParams defines parameters for GetV1AdminBackendQuestions.
1050
type GetV1AdminBackendQuestionsParams struct {
1051
    // Page Page number (1-based)
1052
    Page *int `form:"page,omitempty" json:"page,omitempty"`
1053

1054
    // PageSize Number of questions per page
1055
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
1056

1057
    // Search Search term for question content
1058
    Search *string `form:"search,omitempty" json:"search,omitempty"`
1059

1060
    // Type Filter by question type
1061
    Type *QuestionType `form:"type,omitempty" json:"type,omitempty"`
1062

1063
    // Status Filter by question status
1064
    Status *QuestionStatus `form:"status,omitempty" json:"status,omitempty"`
1065

1066
    // Language Filter by language
1067
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
1068

1069
    // Level Filter by level
1070
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
1071

1072
    // UserId Filter by user ID (optional)
1073
    UserId *int64 `form:"user_id,omitempty" json:"user_id,omitempty"`
1074
}
1075

1076
// GetV1AdminBackendQuestionsPaginatedParams defines parameters for GetV1AdminBackendQuestionsPaginated.
1077
type GetV1AdminBackendQuestionsPaginatedParams struct {
1078
    // Page Page number (1-based)
1079
    Page *int `form:"page,omitempty" json:"page,omitempty"`
1080

1081
    // PageSize Number of questions per page
1082
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
1083

1084
    // Search Search term for question content
1085
    Search *string `form:"search,omitempty" json:"search,omitempty"`
1086

1087
    // Type Filter by question type
1088
    Type *QuestionType `form:"type,omitempty" json:"type,omitempty"`
1089

1090
    // Status Filter by question status
1091
    Status *QuestionStatus `form:"status,omitempty" json:"status,omitempty"`
1092

1093
    // Language Filter by language
1094
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
1095

1096
    // Level Filter by level
1097
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
1098

1099
    // UserId Filter by user ID (optional)
1100
    UserId *int64 `form:"user_id,omitempty" json:"user_id,omitempty"`
1101
}
1102

1103
// PutV1AdminBackendQuestionsIdJSONBody defines parameters for PutV1AdminBackendQuestionsId.
1104
type PutV1AdminBackendQuestionsIdJSONBody struct {
1105
    // Content Updated question content
1106
    Content map[string]interface{} `json:"content"`
1107

1108
    // CorrectAnswer Index of the correct answer
1109
    CorrectAnswer *int `json:"correct_answer,omitempty"`
1110

1111
    // Explanation Explanation for the correct answer
1112
    Explanation string `json:"explanation"`
1113
}
1114

1115
// PostV1AdminBackendQuestionsIdAiFixJSONBody defines parameters for PostV1AdminBackendQuestionsIdAiFix.
1116
type PostV1AdminBackendQuestionsIdAiFixJSONBody struct {
1117
    AdditionalContext *string `json:"additional_context,omitempty"`
1118
}
1119

1120
// PostV1AdminBackendQuestionsIdAssignUsersJSONBody defines parameters for PostV1AdminBackendQuestionsIdAssignUsers.
1121
type PostV1AdminBackendQuestionsIdAssignUsersJSONBody struct {
1122
    // UserIds Array of user IDs to assign to the question
1123
    UserIds []int64 `json:"user_ids"`
1124
}
1125

1126
// PostV1AdminBackendQuestionsIdUnassignUsersJSONBody defines parameters for PostV1AdminBackendQuestionsIdUnassignUsers.
1127
type PostV1AdminBackendQuestionsIdUnassignUsersJSONBody struct {
1128
    // UserIds Array of user IDs to unassign from the question
1129
    UserIds []int64 `json:"user_ids"`
1130
}
1131

1132
// GetV1AdminBackendReportedQuestionsParams defines parameters for GetV1AdminBackendReportedQuestions.
1133
type GetV1AdminBackendReportedQuestionsParams struct {
1134
    // Page Page number (1-based)
1135
    Page *int `form:"page,omitempty" json:"page,omitempty"`
1136

1137
    // PageSize Number of questions per page
1138
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
1139

1140
    // Search Search term for question content
1141
    Search *string `form:"search,omitempty" json:"search,omitempty"`
1142

1143
    // Type Filter by question type
1144
    Type *QuestionType `form:"type,omitempty" json:"type,omitempty"`
1145

1146
    // Language Filter by language
1147
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
1148

1149
    // Level Filter by level
1150
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
1151
}
1152

1153
// PostV1AdminBackendUserzJSONBody defines parameters for PostV1AdminBackendUserz.
1154
type PostV1AdminBackendUserzJSONBody struct {
1155
    // AiEnabled Whether AI is enabled for this user
1156
    AiEnabled *bool `json:"ai_enabled,omitempty"`
1157

1158
    // AiModel AI model preference
1159
    AiModel *string `json:"ai_model,omitempty"`
1160

1161
    // AiProvider AI provider preference
1162
    AiProvider *string `json:"ai_provider,omitempty"`
1163

1164
    // Email Email address for the new user
1165
    Email openapi_types.Email `json:"email"`
1166

1167
    // Language Preferred language for the user
1168
    Language *string `json:"language,omitempty"`
1169

1170
    // Level Current level for the user
1171
    Level *string `json:"level,omitempty"`
1172

1173
    // Password Password for the new user
1174
    Password string `json:"password"`
1175

1176
    // Username Username (1-100 characters, alphanumeric + underscore + email characters, cannot be empty or whitespace-only)
1177
    Username string `json:"username"`
1178
}
1179

1180
// GetV1AdminBackendUserzPaginatedParams defines parameters for GetV1AdminBackendUserzPaginated.
1181
type GetV1AdminBackendUserzPaginatedParams struct {
1182
    // Page Page number (1-based)
1183
    Page *int `form:"page,omitempty" json:"page,omitempty"`
1184

1185
    // PageSize Number of users per page
1186
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
1187

1188
    // Search Search term for username or email
1189
    Search *string `form:"search,omitempty" json:"search,omitempty"`
1190

1191
    // Language Filter by preferred language
1192
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
1193

1194
    // Level Filter by current level
1195
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
1196

1197
    // AiProvider Filter by AI provider
1198
    AiProvider *string `form:"ai_provider,omitempty" json:"ai_provider,omitempty"`
1199

1200
    // AiModel Filter by AI model
1201
    AiModel *string `form:"ai_model,omitempty" json:"ai_model,omitempty"`
1202

1203
    // AiEnabled Filter by AI enabled status
1204
    AiEnabled *GetV1AdminBackendUserzPaginatedParamsAiEnabled `form:"ai_enabled,omitempty" json:"ai_enabled,omitempty"`
1205

1206
    // Active Filter by active status (active within 7 days)
1207
    Active *GetV1AdminBackendUserzPaginatedParamsActive `form:"active,omitempty" json:"active,omitempty"`
1208
}
1209

1210
// GetV1AdminBackendUserzPaginatedParamsAiEnabled defines parameters for GetV1AdminBackendUserzPaginated.
1211
type GetV1AdminBackendUserzPaginatedParamsAiEnabled string
1212

1213
// GetV1AdminBackendUserzPaginatedParamsActive defines parameters for GetV1AdminBackendUserzPaginated.
1214
type GetV1AdminBackendUserzPaginatedParamsActive string
1215

1216
// PostV1AdminBackendUserzIdRolesJSONBody defines parameters for PostV1AdminBackendUserzIdRoles.
1217
type PostV1AdminBackendUserzIdRolesJSONBody struct {
1218
    // RoleId Role ID to assign
1219
    RoleId int64 `json:"role_id"`
1220
}
1221

1222
// GetV1AdminWorkerNotificationsErrorsParams defines parameters for GetV1AdminWorkerNotificationsErrors.
1223
type GetV1AdminWorkerNotificationsErrorsParams struct {
1224
    // Page Page number (1-based)
1225
    Page *int `form:"page,omitempty" json:"page,omitempty"`
1226

1227
    // PageSize Number of errors per page
1228
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
1229

1230
    // ErrorType Filter by error type
1231
    ErrorType *GetV1AdminWorkerNotificationsErrorsParamsErrorType `form:"error_type,omitempty" json:"error_type,omitempty"`
1232

1233
    // NotificationType Filter by notification type
1234
    NotificationType *GetV1AdminWorkerNotificationsErrorsParamsNotificationType `form:"notification_type,omitempty" json:"notification_type,omitempty"`
1235

1236
    // Resolved Filter by resolution status
1237
    Resolved *GetV1AdminWorkerNotificationsErrorsParamsResolved `form:"resolved,omitempty" json:"resolved,omitempty"`
1238
}
1239

1240
// GetV1AdminWorkerNotificationsErrorsParamsErrorType defines parameters for GetV1AdminWorkerNotificationsErrors.
1241
type GetV1AdminWorkerNotificationsErrorsParamsErrorType string
1242

1243
// GetV1AdminWorkerNotificationsErrorsParamsNotificationType defines parameters for GetV1AdminWorkerNotificationsErrors.
1244
type GetV1AdminWorkerNotificationsErrorsParamsNotificationType string
1245

1246
// GetV1AdminWorkerNotificationsErrorsParamsResolved defines parameters for GetV1AdminWorkerNotificationsErrors.
1247
type GetV1AdminWorkerNotificationsErrorsParamsResolved string
1248

1249
// PostV1AdminWorkerNotificationsForceSendJSONBody defines parameters for PostV1AdminWorkerNotificationsForceSend.
1250
type PostV1AdminWorkerNotificationsForceSendJSONBody struct {
1251
    // Username Username of the user to send notification to
1252
    Username string `json:"username"`
1253
}
1254

1255
// GetV1AdminWorkerNotificationsSentParams defines parameters for GetV1AdminWorkerNotificationsSent.
1256
type GetV1AdminWorkerNotificationsSentParams struct {
1257
    // Page Page number (1-based)
1258
    Page *int `form:"page,omitempty" json:"page,omitempty"`
1259

1260
    // PageSize Number of notifications per page
1261
    PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"`
1262

1263
    // NotificationType Filter by notification type
1264
    NotificationType *GetV1AdminWorkerNotificationsSentParamsNotificationType `form:"notification_type,omitempty" json:"notification_type,omitempty"`
1265

1266
    // Status Filter by status
1267
    Status *GetV1AdminWorkerNotificationsSentParamsStatus `form:"status,omitempty" json:"status,omitempty"`
1268

1269
    // SentAfter Filter notifications sent after this timestamp
1270
    SentAfter *string `form:"sent_after,omitempty" json:"sent_after,omitempty"`
1271

1272
    // SentBefore Filter notifications sent before this timestamp
1273
    SentBefore *string `form:"sent_before,omitempty" json:"sent_before,omitempty"`
1274
}
1275

1276
// GetV1AdminWorkerNotificationsSentParamsNotificationType defines parameters for GetV1AdminWorkerNotificationsSent.
1277
type GetV1AdminWorkerNotificationsSentParamsNotificationType string
1278

1279
// GetV1AdminWorkerNotificationsSentParamsStatus defines parameters for GetV1AdminWorkerNotificationsSent.
1280
type GetV1AdminWorkerNotificationsSentParamsStatus string
1281

1282
// PostV1AdminWorkerUsersPauseJSONBody defines parameters for PostV1AdminWorkerUsersPause.
1283
type PostV1AdminWorkerUsersPauseJSONBody struct {
1284
    // UserId ID of the user to pause
1285
    UserId int `json:"user_id"`
1286
}
1287

1288
// PostV1AdminWorkerUsersResumeJSONBody defines parameters for PostV1AdminWorkerUsersResume.
1289
type PostV1AdminWorkerUsersResumeJSONBody struct {
1290
    // UserId ID of the user to resume
1291
    UserId int `json:"user_id"`
1292
}
1293

1294
// GetV1AuthGoogleCallbackParams defines parameters for GetV1AuthGoogleCallback.
1295
type GetV1AuthGoogleCallbackParams struct {
1296
    // Code Authorization code from Google
1297
    Code string `form:"code" json:"code"`
1298

1299
    // State State parameter for CSRF protection
1300
    State *string `form:"state,omitempty" json:"state,omitempty"`
1301
}
1302

1303
// PostV1DailyQuestionsDateAnswerQuestionIdJSONBody defines parameters for PostV1DailyQuestionsDateAnswerQuestionId.
1304
type PostV1DailyQuestionsDateAnswerQuestionIdJSONBody struct {
1305
    // UserAnswerIndex Index of the user's selected answer (0-based)
1306
    UserAnswerIndex int `json:"user_answer_index"`
1307
}
1308

1309
// GetV1QuizQuestionParams defines parameters for GetV1QuizQuestion.
1310
type GetV1QuizQuestionParams struct {
1311
    // Language Preferred language for the question
1312
    Language *Language `form:"language,omitempty" json:"language,omitempty"`
1313

1314
    // Level Difficulty level for the question
1315
    Level *Level `form:"level,omitempty" json:"level,omitempty"`
1316

1317
    // Type Specific question type(s) to retrieve (comma-separated list). If multiple types are provided, the first valid type will be used.
1318
    Type *string `form:"type,omitempty" json:"type,omitempty"`
1319

1320
    // ExcludeType Question type(s) to exclude from random selection (comma-separated list). Useful for filtering out specific question types from the general quiz.
1321
    ExcludeType *string `form:"exclude_type,omitempty" json:"exclude_type,omitempty"`
1322
}
1323

1324
// PostV1QuizQuestionIdMarkKnownJSONBody defines parameters for PostV1QuizQuestionIdMarkKnown.
1325
type PostV1QuizQuestionIdMarkKnownJSONBody struct {
1326
    // ConfidenceLevel User's confidence level (1-5, optional)
1327
    ConfidenceLevel *int `json:"confidence_level,omitempty"`
1328
}
1329

1330
// PostV1QuizQuestionIdReportJSONBody defines parameters for PostV1QuizQuestionIdReport.
1331
type PostV1QuizQuestionIdReportJSONBody struct {
1332
    // ReportReason Optional explanation for why the question is being reported
1333
    ReportReason *string `json:"report_reason,omitempty"`
1334
}
1335

1336
// GetV1SettingsLevelsParams defines parameters for GetV1SettingsLevels.
1337
type GetV1SettingsLevelsParams struct {
1338
    // Language Language to get levels for (optional - returns all levels if not specified)
1339
    Language *string `form:"language,omitempty" json:"language,omitempty"`
1340
}
1341

1342
// PutV1AdminBackendQuestionsIdJSONRequestBody defines body for PutV1AdminBackendQuestionsId for application/json ContentType.
1343
type PutV1AdminBackendQuestionsIdJSONRequestBody PutV1AdminBackendQuestionsIdJSONBody
1344

1345
// PostV1AdminBackendQuestionsIdAiFixJSONRequestBody defines body for PostV1AdminBackendQuestionsIdAiFix for application/json ContentType.
1346
type PostV1AdminBackendQuestionsIdAiFixJSONRequestBody PostV1AdminBackendQuestionsIdAiFixJSONBody
1347

1348
// PostV1AdminBackendQuestionsIdAssignUsersJSONRequestBody defines body for PostV1AdminBackendQuestionsIdAssignUsers for application/json ContentType.
1349
type PostV1AdminBackendQuestionsIdAssignUsersJSONRequestBody PostV1AdminBackendQuestionsIdAssignUsersJSONBody
1350

1351
// PostV1AdminBackendQuestionsIdUnassignUsersJSONRequestBody defines body for PostV1AdminBackendQuestionsIdUnassignUsers for application/json ContentType.
1352
type PostV1AdminBackendQuestionsIdUnassignUsersJSONRequestBody PostV1AdminBackendQuestionsIdUnassignUsersJSONBody
1353

1354
// PostV1AdminBackendUserzJSONRequestBody defines body for PostV1AdminBackendUserz for application/json ContentType.
1355
type PostV1AdminBackendUserzJSONRequestBody PostV1AdminBackendUserzJSONBody
1356

1357
// PutV1AdminBackendUserzIdJSONRequestBody defines body for PutV1AdminBackendUserzId for application/json ContentType.
1358
type PutV1AdminBackendUserzIdJSONRequestBody = UserUpdateRequest
1359

1360
// PostV1AdminBackendUserzIdResetPasswordJSONRequestBody defines body for PostV1AdminBackendUserzIdResetPassword for application/json ContentType.
1361
type PostV1AdminBackendUserzIdResetPasswordJSONRequestBody = PasswordResetRequest
1362

1363
// PostV1AdminBackendUserzIdRolesJSONRequestBody defines body for PostV1AdminBackendUserzIdRoles for application/json ContentType.
1364
type PostV1AdminBackendUserzIdRolesJSONRequestBody PostV1AdminBackendUserzIdRolesJSONBody
1365

1366
// PostV1AdminWorkerNotificationsForceSendJSONRequestBody defines body for PostV1AdminWorkerNotificationsForceSend for application/json ContentType.
1367
type PostV1AdminWorkerNotificationsForceSendJSONRequestBody PostV1AdminWorkerNotificationsForceSendJSONBody
1368

1369
// PostV1AdminWorkerUsersPauseJSONRequestBody defines body for PostV1AdminWorkerUsersPause for application/json ContentType.
1370
type PostV1AdminWorkerUsersPauseJSONRequestBody PostV1AdminWorkerUsersPauseJSONBody
1371

1372
// PostV1AdminWorkerUsersResumeJSONRequestBody defines body for PostV1AdminWorkerUsersResume for application/json ContentType.
1373
type PostV1AdminWorkerUsersResumeJSONRequestBody PostV1AdminWorkerUsersResumeJSONBody
1374

1375
// PostV1AudioSpeechJSONRequestBody defines body for PostV1AudioSpeech for application/json ContentType.
1376
type PostV1AudioSpeechJSONRequestBody = TTSRequest
1377

1378
// PostV1AuthLoginJSONRequestBody defines body for PostV1AuthLogin for application/json ContentType.
1379
type PostV1AuthLoginJSONRequestBody = LoginRequest
1380

1381
// PostV1AuthSignupJSONRequestBody defines body for PostV1AuthSignup for application/json ContentType.
1382
type PostV1AuthSignupJSONRequestBody = UserCreateRequest
1383

1384
// PostV1DailyQuestionsDateAnswerQuestionIdJSONRequestBody defines body for PostV1DailyQuestionsDateAnswerQuestionId for application/json ContentType.
1385
type PostV1DailyQuestionsDateAnswerQuestionIdJSONRequestBody PostV1DailyQuestionsDateAnswerQuestionIdJSONBody
1386

1387
// PutV1PreferencesLearningJSONRequestBody defines body for PutV1PreferencesLearning for application/json ContentType.
1388
type PutV1PreferencesLearningJSONRequestBody = UserLearningPreferences
1389

1390
// PostV1QuizAnswerJSONRequestBody defines body for PostV1QuizAnswer for application/json ContentType.
1391
type PostV1QuizAnswerJSONRequestBody = AnswerRequest
1392

1393
// PostV1QuizChatStreamJSONRequestBody defines body for PostV1QuizChatStream for application/json ContentType.
1394
type PostV1QuizChatStreamJSONRequestBody = QuizChatRequest
1395

1396
// PostV1QuizQuestionIdMarkKnownJSONRequestBody defines body for PostV1QuizQuestionIdMarkKnown for application/json ContentType.
1397
type PostV1QuizQuestionIdMarkKnownJSONRequestBody PostV1QuizQuestionIdMarkKnownJSONBody
1398

1399
// PostV1QuizQuestionIdReportJSONRequestBody defines body for PostV1QuizQuestionIdReport for application/json ContentType.
1400
type PostV1QuizQuestionIdReportJSONRequestBody PostV1QuizQuestionIdReportJSONBody
1401

1402
// PutV1SettingsJSONRequestBody defines body for PutV1Settings for application/json ContentType.
1403
type PutV1SettingsJSONRequestBody = UserSettings
1404

1405
// PostV1SettingsTestAiJSONRequestBody defines body for PostV1SettingsTestAi for application/json ContentType.
1406
type PostV1SettingsTestAiJSONRequestBody = TestAIRequest
1407

1408
// PutV1UserzProfileJSONRequestBody defines body for PutV1UserzProfile for application/json ContentType.
1409
type PutV1UserzProfileJSONRequestBody = UserUpdateRequest
1410

1411
// AsServiceVersion returns the union data inside the AggregatedVersion_Worker as a ServiceVersion
1412
func (t AggregatedVersion_Worker) AsServiceVersion() (ServiceVersion, error) {
1413
    var body ServiceVersion
1414
    err := json.Unmarshal(t.union, &body)
1415
    return body, err
1416
}
1417

1418
// FromServiceVersion overwrites any union data inside the AggregatedVersion_Worker as the provided ServiceVersion
1419
func (t *AggregatedVersion_Worker) FromServiceVersion(v ServiceVersion) error {
1420
    b, err := json.Marshal(v)
1421
    t.union = b
1422
    return err
1423
}
1424

1425
// MergeServiceVersion performs a merge with any union data inside the AggregatedVersion_Worker, using the provided ServiceVersion
1426
func (t *AggregatedVersion_Worker) MergeServiceVersion(v ServiceVersion) error {
1427
    b, err := json.Marshal(v)
1428
    if err != nil {
1429
        return err
1430
    }
1431

1432
    merged, err := runtime.JSONMerge(t.union, b)
1433
    t.union = merged
1434
    return err
1435
}
1436

1437
// AsAggregatedVersionWorker1 returns the union data inside the AggregatedVersion_Worker as a AggregatedVersionWorker1
1438
func (t AggregatedVersion_Worker) AsAggregatedVersionWorker1() (AggregatedVersionWorker1, error) {
1439
    var body AggregatedVersionWorker1
1440
    err := json.Unmarshal(t.union, &body)
1441
    return body, err
1442
}
1443

1444
// FromAggregatedVersionWorker1 overwrites any union data inside the AggregatedVersion_Worker as the provided AggregatedVersionWorker1
1445
func (t *AggregatedVersion_Worker) FromAggregatedVersionWorker1(v AggregatedVersionWorker1) error {
1446
    b, err := json.Marshal(v)
1447
    t.union = b
1448
    return err
1449
}
1450

1451
// MergeAggregatedVersionWorker1 performs a merge with any union data inside the AggregatedVersion_Worker, using the provided AggregatedVersionWorker1
1452
func (t *AggregatedVersion_Worker) MergeAggregatedVersionWorker1(v AggregatedVersionWorker1) error {
1453
    b, err := json.Marshal(v)
1454
    if err != nil {
1455
        return err
1456
    }
1457

1458
    merged, err := runtime.JSONMerge(t.union, b)
1459
    t.union = merged
1460
    return err
1461
}
1462

1463
func (t AggregatedVersion_Worker) MarshalJSON() ([]byte, error) {
1464
    b, err := t.union.MarshalJSON()
1465
    return b, err
1466
}
1467

1468
func (t *AggregatedVersion_Worker) UnmarshalJSON(b []byte) error {
1469
    err := t.union.UnmarshalJSON(b)
1470
    return err
1471
}
1472

1473
// AsUserSettings0 returns the union data inside the UserSettings as a UserSettings0
1474
func (t UserSettings) AsUserSettings0() (UserSettings0, error) {
1475
    var body UserSettings0
1476
    err := json.Unmarshal(t.union, &body)
1477
    return body, err
1478
}
1479

1480
// FromUserSettings0 overwrites any union data inside the UserSettings as the provided UserSettings0
1481
func (t *UserSettings) FromUserSettings0(v UserSettings0) error {
1482
    b, err := json.Marshal(v)
1483
    t.union = b
1484
    return err
1485
}
1486

1487
// MergeUserSettings0 performs a merge with any union data inside the UserSettings, using the provided UserSettings0
1488
func (t *UserSettings) MergeUserSettings0(v UserSettings0) error {
1489
    b, err := json.Marshal(v)
1490
    if err != nil {
1491
        return err
1492
    }
1493

1494
    merged, err := runtime.JSONMerge(t.union, b)
1495
    t.union = merged
1496
    return err
1497
}
1498

1499
// AsUserSettings1 returns the union data inside the UserSettings as a UserSettings1
1500
func (t UserSettings) AsUserSettings1() (UserSettings1, error) {
1501
    var body UserSettings1
1502
    err := json.Unmarshal(t.union, &body)
1503
    return body, err
1504
}
1505

1506
// FromUserSettings1 overwrites any union data inside the UserSettings as the provided UserSettings1
1507
func (t *UserSettings) FromUserSettings1(v UserSettings1) error {
1508
    b, err := json.Marshal(v)
1509
    t.union = b
1510
    return err
1511
}
1512

1513
// MergeUserSettings1 performs a merge with any union data inside the UserSettings, using the provided UserSettings1
1514
func (t *UserSettings) MergeUserSettings1(v UserSettings1) error {
1515
    b, err := json.Marshal(v)
1516
    if err != nil {
1517
        return err
1518
    }
1519

1520
    merged, err := runtime.JSONMerge(t.union, b)
1521
    t.union = merged
1522
    return err
1523
}
1524

1525
func (t UserSettings) MarshalJSON() ([]byte, error) {
1526
    b, err := t.union.MarshalJSON()
1527
    if err != nil {
1528
        return nil, err
1529
    }
1530
    object := make(map[string]json.RawMessage)
1531
    if t.union != nil {
1532
        err = json.Unmarshal(b, &object)
1533
        if err != nil {
1534
            return nil, err
1535
        }
1536
    }
1537

1538
    if t.AiEnabled != nil {
1539
        object["ai_enabled"], err = json.Marshal(t.AiEnabled)
1540
        if err != nil {
1541
            return nil, fmt.Errorf("error marshaling 'ai_enabled': %w", err)
1542
        }
1543
    }
1544

1545
    if t.AiModel != nil {
1546
        object["ai_model"], err = json.Marshal(t.AiModel)
1547
        if err != nil {
1548
            return nil, fmt.Errorf("error marshaling 'ai_model': %w", err)
1549
        }
1550
    }
1551

1552
    if t.AiProvider != nil {
1553
        object["ai_provider"], err = json.Marshal(t.AiProvider)
1554
        if err != nil {
1555
            return nil, fmt.Errorf("error marshaling 'ai_provider': %w", err)
1556
        }
1557
    }
1558

1559
    if t.ApiKey != nil {
1560
        object["api_key"], err = json.Marshal(t.ApiKey)
1561
        if err != nil {
1562
            return nil, fmt.Errorf("error marshaling 'api_key': %w", err)
1563
        }
1564
    }
1565

1566
    if t.Language != nil {
1567
        object["language"], err = json.Marshal(t.Language)
1568
        if err != nil {
1569
            return nil, fmt.Errorf("error marshaling 'language': %w", err)
1570
        }
1571
    }
1572

1573
    if t.Level != nil {
1574
        object["level"], err = json.Marshal(t.Level)
1575
        if err != nil {
1576
            return nil, fmt.Errorf("error marshaling 'level': %w", err)
1577
        }
1578
    }
1579
    b, err = json.Marshal(object)
1580
    return b, err
1581
}
1582

1583
func (t *UserSettings) UnmarshalJSON(b []byte) error {
1584
    err := t.union.UnmarshalJSON(b)
1585
    if err != nil {
1586
        return err
1587
    }
1588
    object := make(map[string]json.RawMessage)
1589
    err = json.Unmarshal(b, &object)
1590
    if err != nil {
1591
        return err
1592
    }
1593

1594
    if raw, found := object["ai_enabled"]; found {
1595
        err = json.Unmarshal(raw, &t.AiEnabled)
1596
        if err != nil {
1597
            return fmt.Errorf("error reading 'ai_enabled': %w", err)
1598
        }
1599
    }
1600

1601
    if raw, found := object["ai_model"]; found {
1602
        err = json.Unmarshal(raw, &t.AiModel)
1603
        if err != nil {
1604
            return fmt.Errorf("error reading 'ai_model': %w", err)
1605
        }
1606
    }
1607

1608
    if raw, found := object["ai_provider"]; found {
1609
        err = json.Unmarshal(raw, &t.AiProvider)
1610
        if err != nil {
1611
            return fmt.Errorf("error reading 'ai_provider': %w", err)
1612
        }
1613
    }
1614

1615
    if raw, found := object["api_key"]; found {
1616
        err = json.Unmarshal(raw, &t.ApiKey)
1617
        if err != nil {
1618
            return fmt.Errorf("error reading 'api_key': %w", err)
1619
        }
1620
    }
1621

1622
    if raw, found := object["language"]; found {
1623
        err = json.Unmarshal(raw, &t.Language)
1624
        if err != nil {
1625
            return fmt.Errorf("error reading 'language': %w", err)
1626
        }
1627
    }
1628

1629
    if raw, found := object["level"]; found {
1630
        err = json.Unmarshal(raw, &t.Level)
1631
        if err != nil {
1632
            return fmt.Errorf("error reading 'level': %w", err)
1633
        }
1634
    }
1635

1636
    return err
1637
}
1638

1639
// AsUserUpdateRequest0 returns the union data inside the UserUpdateRequest as a UserUpdateRequest0
1640
func (t UserUpdateRequest) AsUserUpdateRequest0() (UserUpdateRequest0, error) {
1641
    var body UserUpdateRequest0
1642
    err := json.Unmarshal(t.union, &body)
1643
    return body, err
1644
}
1645

1646
// FromUserUpdateRequest0 overwrites any union data inside the UserUpdateRequest as the provided UserUpdateRequest0
1647
func (t *UserUpdateRequest) FromUserUpdateRequest0(v UserUpdateRequest0) error {
1648
    b, err := json.Marshal(v)
1649
    t.union = b
1650
    return err
1651
}
1652

1653
// MergeUserUpdateRequest0 performs a merge with any union data inside the UserUpdateRequest, using the provided UserUpdateRequest0
1654
func (t *UserUpdateRequest) MergeUserUpdateRequest0(v UserUpdateRequest0) error {
1655
    b, err := json.Marshal(v)
1656
    if err != nil {
1657
        return err
1658
    }
1659

1660
    merged, err := runtime.JSONMerge(t.union, b)
1661
    t.union = merged
1662
    return err
1663
}
1664

1665
// AsUserUpdateRequest1 returns the union data inside the UserUpdateRequest as a UserUpdateRequest1
1666
func (t UserUpdateRequest) AsUserUpdateRequest1() (UserUpdateRequest1, error) {
1667
    var body UserUpdateRequest1
1668
    err := json.Unmarshal(t.union, &body)
1669
    return body, err
1670
}
1671

1672
// FromUserUpdateRequest1 overwrites any union data inside the UserUpdateRequest as the provided UserUpdateRequest1
1673
func (t *UserUpdateRequest) FromUserUpdateRequest1(v UserUpdateRequest1) error {
1674
    b, err := json.Marshal(v)
1675
    t.union = b
1676
    return err
1677
}
1678

1679
// MergeUserUpdateRequest1 performs a merge with any union data inside the UserUpdateRequest, using the provided UserUpdateRequest1
1680
func (t *UserUpdateRequest) MergeUserUpdateRequest1(v UserUpdateRequest1) error {
1681
    b, err := json.Marshal(v)
1682
    if err != nil {
1683
        return err
1684
    }
1685

1686
    merged, err := runtime.JSONMerge(t.union, b)
1687
    t.union = merged
1688
    return err
1689
}
1690

1691
func (t UserUpdateRequest) MarshalJSON() ([]byte, error) {
1692
    b, err := t.union.MarshalJSON()
1693
    if err != nil {
1694
        return nil, err
1695
    }
1696
    object := make(map[string]json.RawMessage)
1697
    if t.union != nil {
1698
        err = json.Unmarshal(b, &object)
1699
        if err != nil {
1700
            return nil, err
1701
        }
1702
    }
1703

1704
    if t.AiEnabled != nil {
1705
        object["ai_enabled"], err = json.Marshal(t.AiEnabled)
1706
        if err != nil {
1707
            return nil, fmt.Errorf("error marshaling 'ai_enabled': %w", err)
1708
        }
1709
    }
1710

1711
    if t.AiModel != nil {
1712
        object["ai_model"], err = json.Marshal(t.AiModel)
1713
        if err != nil {
1714
            return nil, fmt.Errorf("error marshaling 'ai_model': %w", err)
1715
        }
1716
    }
1717

1718
    if t.AiProvider != nil {
1719
        object["ai_provider"], err = json.Marshal(t.AiProvider)
1720
        if err != nil {
1721
            return nil, fmt.Errorf("error marshaling 'ai_provider': %w", err)
1722
        }
1723
    }
1724

1725
    if t.ApiKey != nil {
1726
        object["api_key"], err = json.Marshal(t.ApiKey)
1727
        if err != nil {
1728
            return nil, fmt.Errorf("error marshaling 'api_key': %w", err)
1729
        }
1730
    }
1731

1732
    if t.CurrentLevel != nil {
1733
        object["current_level"], err = json.Marshal(t.CurrentLevel)
1734
        if err != nil {
1735
            return nil, fmt.Errorf("error marshaling 'current_level': %w", err)
1736
        }
1737
    }
1738

1739
    if t.Email != nil {
1740
        object["email"], err = json.Marshal(t.Email)
1741
        if err != nil {
1742
            return nil, fmt.Errorf("error marshaling 'email': %w", err)
1743
        }
1744
    }
1745

1746
    if t.PreferredLanguage != nil {
1747
        object["preferred_language"], err = json.Marshal(t.PreferredLanguage)
1748
        if err != nil {
1749
            return nil, fmt.Errorf("error marshaling 'preferred_language': %w", err)
1750
        }
1751
    }
1752

1753
    if t.SelectedRoles != nil {
1754
        object["selectedRoles"], err = json.Marshal(t.SelectedRoles)
1755
        if err != nil {
1756
            return nil, fmt.Errorf("error marshaling 'selectedRoles': %w", err)
1757
        }
1758
    }
1759

1760
    if t.Timezone != nil {
1761
        object["timezone"], err = json.Marshal(t.Timezone)
1762
        if err != nil {
1763
            return nil, fmt.Errorf("error marshaling 'timezone': %w", err)
1764
        }
1765
    }
1766

1767
    if t.Username != nil {
1768
        object["username"], err = json.Marshal(t.Username)
1769
        if err != nil {
1770
            return nil, fmt.Errorf("error marshaling 'username': %w", err)
1771
        }
1772
    }
1773
    b, err = json.Marshal(object)
1774
    return b, err
1775
}
1776

1777
func (t *UserUpdateRequest) UnmarshalJSON(b []byte) error {
1778
    err := t.union.UnmarshalJSON(b)
1779
    if err != nil {
1780
        return err
1781
    }
1782
    object := make(map[string]json.RawMessage)
1783
    err = json.Unmarshal(b, &object)
1784
    if err != nil {
1785
        return err
1786
    }
1787

1788
    if raw, found := object["ai_enabled"]; found {
1789
        err = json.Unmarshal(raw, &t.AiEnabled)
1790
        if err != nil {
1791
            return fmt.Errorf("error reading 'ai_enabled': %w", err)
1792
        }
1793
    }
1794

1795
    if raw, found := object["ai_model"]; found {
1796
        err = json.Unmarshal(raw, &t.AiModel)
1797
        if err != nil {
1798
            return fmt.Errorf("error reading 'ai_model': %w", err)
1799
        }
1800
    }
1801

1802
    if raw, found := object["ai_provider"]; found {
1803
        err = json.Unmarshal(raw, &t.AiProvider)
1804
        if err != nil {
1805
            return fmt.Errorf("error reading 'ai_provider': %w", err)
1806
        }
1807
    }
1808

1809
    if raw, found := object["api_key"]; found {
1810
        err = json.Unmarshal(raw, &t.ApiKey)
1811
        if err != nil {
1812
            return fmt.Errorf("error reading 'api_key': %w", err)
1813
        }
1814
    }
1815

1816
    if raw, found := object["current_level"]; found {
1817
        err = json.Unmarshal(raw, &t.CurrentLevel)
1818
        if err != nil {
1819
            return fmt.Errorf("error reading 'current_level': %w", err)
1820
        }
1821
    }
1822

1823
    if raw, found := object["email"]; found {
1824
        err = json.Unmarshal(raw, &t.Email)
1825
        if err != nil {
1826
            return fmt.Errorf("error reading 'email': %w", err)
1827
        }
1828
    }
1829

1830
    if raw, found := object["preferred_language"]; found {
1831
        err = json.Unmarshal(raw, &t.PreferredLanguage)
1832
        if err != nil {
1833
            return fmt.Errorf("error reading 'preferred_language': %w", err)
1834
        }
1835
    }
1836

1837
    if raw, found := object["selectedRoles"]; found {
1838
        err = json.Unmarshal(raw, &t.SelectedRoles)
1839
        if err != nil {
1840
            return fmt.Errorf("error reading 'selectedRoles': %w", err)
1841
        }
1842
    }
1843

1844
    if raw, found := object["timezone"]; found {
1845
        err = json.Unmarshal(raw, &t.Timezone)
1846
        if err != nil {
1847
            return fmt.Errorf("error reading 'timezone': %w", err)
1848
        }
1849
    }
1850

1851
    if raw, found := object["username"]; found {
1852
        err = json.Unmarshal(raw, &t.Username)
1853
        if err != nil {
1854
            return fmt.Errorf("error reading 'username': %w", err)
1855
        }
1856
    }
1857

1858
    return err
1859
}
1860


			
quizapp internal config
89.6%
Statements
121/135
config.go
89.6%
121/135
quizapp internal config config.go
89.6%
Statements
121/135
1
// Package config handles application configuration loading from environment variables.
2
package config
3

4
import (
5
    "os"
6
    "reflect"
7
    "sort"
8
    "strconv"
9
    "strings"
10
    "time"
11

12
    contextutils "quizapp/internal/utils"
13

14
    "gopkg.in/yaml.v3"
15
)
16

17
// ProviderConfig defines the structure for a single provider
18
type ProviderConfig struct {
19
    Name              string    `json:"name" yaml:"name"`
20
    Code              string    `json:"code" yaml:"code"`
21
    URL               string    `json:"url,omitempty" yaml:"url,omitempty"`
22
    SupportsGrammar   bool      `json:"supports_grammar,omitempty" yaml:"supports_grammar,omitempty"`
23
    QuestionBatchSize int       `json:"question_batch_size,omitempty" yaml:"question_batch_size,omitempty"`
24
    Models            []AIModel `json:"models" yaml:"models"`
25
}
26

27
// AIModel represents an AI model configuration
28
type AIModel struct {
29
    Name      string `json:"name" yaml:"name"`
30
    Code      string `json:"code" yaml:"code"`
31
    MaxTokens int    `json:"max_tokens,omitempty" yaml:"max_tokens,omitempty"`
32
}
33

34
// QuestionVarietyConfig defines the variety configuration for question generation
35
type QuestionVarietyConfig struct {
36
    TopicCategories     []string            `json:"topic_categories" yaml:"topic_categories"`
37
    GrammarFocusByLevel map[string][]string `json:"grammar_focus_by_level" yaml:"grammar_focus_by_level"`
38
    GrammarFocus        []string            `json:"grammar_focus" yaml:"grammar_focus"`
39
    VocabularyDomains   []string            `json:"vocabulary_domains" yaml:"vocabulary_domains"`
40
    Scenarios           []string            `json:"scenarios" yaml:"scenarios"`
41
    StyleModifiers      []string            `json:"style_modifiers" yaml:"style_modifiers"`
42
    DifficultyModifiers []string            `json:"difficulty_modifiers" yaml:"difficulty_modifiers"`
43
    TimeContexts        []string            `json:"time_contexts" yaml:"time_contexts"`
44
}
45

46
// LanguageLevelConfig represents the levels and descriptions for a specific language
47
type LanguageLevelConfig struct {
48
    Levels       []string          `json:"levels" yaml:"levels"`
49
    Descriptions map[string]string `json:"descriptions" yaml:"descriptions"`
50
}
51

52
// AuthConfig represents authentication-related configuration
53
type AuthConfig struct {
54
    SignupsDisabled bool     `json:"signups_disabled" yaml:"signups_disabled"`
55
    AllowedDomains  []string `json:"allowed_domains,omitempty" yaml:"allowed_domains,omitempty"`
56
    AllowedEmails   []string `json:"allowed_emails,omitempty" yaml:"allowed_emails,omitempty"`
57
}
58

59
// SystemConfig represents system-wide configuration
60
type SystemConfig struct {
61
    Auth AuthConfig `json:"auth" yaml:"auth"`
62
}
63

64
// Config holds all configuration for the application
65
type Config struct {
66
    // Server configuration
67
    Server ServerConfig `json:"server" yaml:"server"`
68

69
    // Database configuration
70
    Database DatabaseConfig `json:"database" yaml:"database"`
71

72
    // AI Providers and Language Levels
73
    Providers      []ProviderConfig               `json:"providers" yaml:"providers"`
74
    LanguageLevels map[string]LanguageLevelConfig `json:"language_levels" yaml:"language_levels"`
75
    Variety        *QuestionVarietyConfig         `json:"variety,omitempty" yaml:"variety,omitempty"`
76
    System         *SystemConfig                  `json:"system,omitempty" yaml:"system,omitempty"`
77

78
    // OAuth Configuration
79
    GoogleOAuthClientID     string `json:"google_oauth_client_id" yaml:"google_oauth_client_id"`
80
    GoogleOAuthClientSecret string `json:"google_oauth_client_secret" yaml:"google_oauth_client_secret"`
81
    GoogleOAuthRedirectURL  string `json:"google_oauth_redirect_url" yaml:"google_oauth_redirect_url"`
82

83
    // OpenTelemetry Configuration
84
    OpenTelemetry OpenTelemetryConfig `json:"open_telemetry" yaml:"open_telemetry"`
85

86
    // Email Configuration
87
    Email EmailConfig `json:"email" yaml:"email"`
88

89
    // Internal fields
90
    IsTest bool `json:"is_test" yaml:"is_test"`
91
}
92

93
// ServerConfig represents server configuration
94
type ServerConfig struct {
95
    Port                    string   `json:"port" yaml:"port"`
96
    WorkerPort              string   `json:"worker_port" yaml:"worker_port"`
97
    AdminUsername           string   `json:"admin_username" yaml:"admin_username"`
98
    AdminPassword           string   `json:"admin_password" yaml:"admin_password"`
99
    SessionSecret           string   `json:"session_secret" yaml:"session_secret"`
100
    Debug                   bool     `json:"debug" yaml:"debug"`
101
    LogLevel                string   `json:"log_level" yaml:"log_level"`
102
    WorkerBaseURL           string   `json:"worker_base_url" yaml:"worker_base_url"`
103
    WorkerInternalURL       string   `json:"worker_internal_url" yaml:"worker_internal_url"`
104
    BackendBaseURL          string   `json:"backend_base_url" yaml:"backend_base_url"`
105
    AppBaseURL              string   `json:"app_base_url" yaml:"app_base_url"`
106
    MaxAIConcurrent         int      `json:"max_ai_concurrent" yaml:"max_ai_concurrent"`
107
    MaxAIPerUser            int      `json:"max_ai_per_user" yaml:"max_ai_per_user"`
108
    CORSOrigins             []string `json:"cors_origins" yaml:"cors_origins"`
109
    QuestionRefillThreshold int      `json:"question_refill_threshold" yaml:"question_refill_threshold"`
110
    // DailyFreshQuestionRatio controls the minimum fraction of fresh (never-seen)
111
    // questions to aim for when refilling question pools (0.0 - 1.0). Example: 0.35
112
    // means at least 35% fresh questions when refilling.
113
    DailyFreshQuestionRatio float64 `json:"daily_fresh_question_ratio" yaml:"daily_fresh_question_ratio"`
114
    MaxHistory              int     `json:"max_history" yaml:"max_history"`
115
    MaxActivityLogs         int     `json:"max_activity_logs" yaml:"max_activity_logs"`
116
    DailyRepeatAvoidDays    int     `json:"daily_repeat_avoid_days" yaml:"daily_repeat_avoid_days"`
117
    // DailyHorizonDays controls how many days ahead the worker will assign
118
    // daily questions (e.g. 0 = today only, 1 = today+1, ...). If unset or
119
    // <= 0 the worker will fall back to the DAILY_HORIZON_DAYS environment
120
    // variable (default 1).
121
    DailyHorizonDays int `json:"daily_horizon_days" yaml:"daily_horizon_days"`
122
}
123

124
// GetLanguages returns a slice of all supported languages (derived from language_levels keys)
125
4x
func (c *Config) GetLanguages() []string {
126
4x
    if c.LanguageLevels == nil {
127
        return []string{}
128
    }
129

130
4x
    languages := make([]string, 0, len(c.LanguageLevels))
131
4x
    for lang := range c.LanguageLevels {
132
18x
        languages = append(languages, lang)
133
18x
    }
134

135
4x
    sort.Strings(languages)
136
4x
    return languages
137
}
138

139
// GetLevelsForLanguage returns the levels for a specific language
140
8x
func (c *Config) GetLevelsForLanguage(language string) []string {
141
8x
    if c.LanguageLevels == nil {
142
        return []string{}
143
    }
144

145
8x
    langConfig, exists := c.LanguageLevels[language]
146
8x
    if !exists {
147
3x
        return []string{}
148
3x
    }
149

150
5x
    return langConfig.Levels
151
}
152

153
// GetLevelDescriptionsForLanguage returns the level descriptions for a specific language
154
7x
func (c *Config) GetLevelDescriptionsForLanguage(language string) map[string]string {
155
7x
    if c.LanguageLevels == nil {
156
        return map[string]string{}
157
    }
158

159
7x
    langConfig, exists := c.LanguageLevels[language]
160
7x
    if !exists {
161
3x
        return map[string]string{}
162
3x
    }
163

164
4x
    return langConfig.Descriptions
165
}
166

167
// GetAllLevels returns all unique levels across all languages
168
4x
func (c *Config) GetAllLevels() []string {
169
4x
    if c.LanguageLevels == nil {
170
        return []string{}
171
    }
172

173
4x
    levelSet := make(map[string]bool)
174
4x
    for _, langConfig := range c.LanguageLevels {
175
16x
        for _, level := range langConfig.Levels {
176
98x
            levelSet[level] = true
177
98x
        }
178
    }
179

180
4x
    levels := make([]string, 0, len(levelSet))
181
4x
    for level := range levelSet {
182
46x
        levels = append(levels, level)
183
46x
    }
184

185
4x
    sort.Strings(levels)
186
4x
    return levels
187
}
188

189
// GetAllLevelDescriptions returns all unique level descriptions across all languages
190
4x
func (c *Config) GetAllLevelDescriptions() map[string]string {
191
4x
    if c.LanguageLevels == nil {
192
        return map[string]string{}
193
    }
194

195
4x
    descriptions := make(map[string]string)
196
4x
    for _, langConfig := range c.LanguageLevels {
197
16x
        for level, description := range langConfig.Descriptions {
198
94x
            descriptions[level] = description
199
94x
        }
200
    }
201

202
4x
    return descriptions
203
}
204

205
// Languages returns all supported languages
206
1x
func (c *Config) Languages() []string {
207
1x
    return c.GetLanguages()
208
1x
}
209

210
// Levels returns all unique levels
211
1x
func (c *Config) Levels() []string {
212
1x
    return c.GetAllLevels()
213
1x
}
214

215
// LevelDescriptions returns all unique level descriptions
216
1x
func (c *Config) LevelDescriptions() map[string]string {
217
1x
    return c.GetAllLevelDescriptions()
218
1x
}
219

220
// IsSignupDisabled returns whether signups are disabled based on configuration
221
4x
func (c *Config) IsSignupDisabled() bool {
222
4x
    if c.System == nil {
223
1x
        return false // Default to enabled if no config
224
1x
    }
225
3x
    return c.System.Auth.SignupsDisabled
226
}
227

228
// IsEmailAllowed checks if an email is allowed for OAuth signup override
229
19x
func (c *Config) IsEmailAllowed(email string) bool {
230
19x
    if c.System == nil || c.System.Auth.AllowedEmails == nil {
231
4x
        return false
232
4x
    }
233

234
15x
    normalizedEmail := strings.ToLower(strings.TrimSpace(email))
235
15x
    for _, allowedEmail := range c.System.Auth.AllowedEmails {
236
16x
        if strings.ToLower(strings.TrimSpace(allowedEmail)) == normalizedEmail {
237
6x
            return true
238
6x
        }
239
    }
240
9x
    return false
241
}
242

243
// IsDomainAllowed checks if a domain is allowed for OAuth signup override
244
16x
func (c *Config) IsDomainAllowed(domain string) bool {
245
16x
    if c.System == nil || c.System.Auth.AllowedDomains == nil {
246
4x
        return false
247
4x
    }
248

249
12x
    normalizedDomain := strings.ToLower(strings.TrimSpace(domain))
250
12x
    for _, allowedDomain := range c.System.Auth.AllowedDomains {
251
13x
        if strings.ToLower(strings.TrimSpace(allowedDomain)) == normalizedDomain {
252
6x
            return true
253
6x
        }
254
    }
255
6x
    return false
256
}
257

258
// IsOAuthSignupAllowed checks if OAuth signup is allowed for a given email
259
17x
func (c *Config) IsOAuthSignupAllowed(email string) bool {
260
17x
    if c.System == nil {
261
1x
        return false
262
1x
    }
263

264
    // If signups are not disabled, OAuth signup is always allowed
265
16x
    if !c.System.Auth.SignupsDisabled {
266
2x
        return true
267
2x
    }
268

269
    // If signups are disabled, check whitelist
270
14x
    normalizedEmail := strings.ToLower(strings.TrimSpace(email))
271
14x

272
14x
    // Use the shared email validation function
273
14x
    if !contextutils.IsValidEmail(normalizedEmail) {
274
4x
        return false
275
4x
    }
276

277
    // Check if email is directly whitelisted
278
10x
    if c.IsEmailAllowed(normalizedEmail) {
279
2x
        return true
280
2x
    }
281

282
    // Extract domain from email and check if domain is whitelisted
283
8x
    parts := strings.Split(normalizedEmail, "@")
284
8x
    domain := parts[1]
285
8x
    return c.IsDomainAllowed(domain)
286
}
287

288
// OpenTelemetryConfig holds all OpenTelemetry-related configuration
289
type OpenTelemetryConfig struct {
290
    Endpoint       string            `json:"endpoint" yaml:"endpoint"`               // Default: "http://localhost:4317"
291
    Protocol       string            `json:"protocol" yaml:"protocol"`               // "grpc" or "http", default: "grpc"
292
    Insecure       bool              `json:"insecure" yaml:"insecure"`               // Default: true (for localhost)
293
    Headers        map[string]string `json:"headers" yaml:"headers"`                 // For authenticated endpoints
294
    ServiceName    string            `json:"service_name" yaml:"service_name"`       // Default: "quiz-backend" or "quiz-worker"
295
    ServiceVersion string            `json:"service_version" yaml:"service_version"` // From version package
296
    EnableTracing  bool              `json:"enable_tracing" yaml:"enable_tracing"`   // Default: true
297
    EnableMetrics  bool              `json:"enable_metrics" yaml:"enable_metrics"`   // Default: true
298
    EnableLogging  bool              `json:"enable_logging" yaml:"enable_logging"`   // Default: true (future)
299
    SamplingRate   float64           `json:"sampling_rate" yaml:"sampling_rate"`     // Default: 1.0 (100%)
300
}
301

302
// DatabaseConfig represents database configuration
303
type DatabaseConfig struct {
304
    URL             string        `json:"url" yaml:"url"`
305
    MaxOpenConns    int           `json:"max_open_conns" yaml:"max_open_conns"`       // Maximum number of open connections to the database
306
    MaxIdleConns    int           `json:"max_idle_conns" yaml:"max_idle_conns"`       // Maximum number of idle connections in the pool
307
    ConnMaxLifetime time.Duration `json:"conn_max_lifetime" yaml:"conn_max_lifetime"` // Maximum amount of time a connection may be reused
308
}
309

310
// EmailConfig represents email/SMTP configuration
311
type EmailConfig struct {
312
    SMTP          SMTPConfig          `json:"smtp" yaml:"smtp"`
313
    DailyReminder DailyReminderConfig `json:"daily_reminder" yaml:"daily_reminder"`
314
    Enabled       bool                `json:"enabled" yaml:"enabled"`
315
}
316

317
// SMTPConfig represents SMTP server configuration
318
type SMTPConfig struct {
319
    Host        string `json:"host" yaml:"host"`
320
    Port        int    `json:"port" yaml:"port"`
321
    Username    string `json:"username" yaml:"username"`
322
    Password    string `json:"password" yaml:"password"`
323
    FromAddress string `json:"from_address" yaml:"from_address"`
324
    FromName    string `json:"from_name" yaml:"from_name"`
325
}
326

327
// DailyReminderConfig represents daily reminder email configuration
328
type DailyReminderConfig struct {
329
    Enabled bool `json:"enabled" yaml:"enabled"`
330
    Hour    int  `json:"hour" yaml:"hour"` // Hour of day to send (0-23)
331
}
332

333
// NewConfig loads configuration from YAML file first, then overrides with environment variables
334
32x
func NewConfig() (result0 *Config, err error) {
335
32x
    // Load config from YAML file
336
32x
    config, err := loadConfigWithOverrides()
337
32x
    if err != nil {
338
3x
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to load config: %w", err)
339
3x
    }
340

341
    // Override with environment variables
342
29x
    config.overrideFromEnv()
343
29x

344
29x
    return config, nil
345
}
346

347
// overrideFromEnv overrides config values with environment variables using reflection
348
29x
func (c *Config) overrideFromEnv() {
349
29x
    overrideStructFromEnv(c)
350
29x
}
351

352
// overrideStructFromEnv recursively overrides struct fields with environment variables
353
37x
func overrideStructFromEnv(v interface{}) {
354
37x
    overrideStructFromEnvWithPrefix(v, "")
355
37x
}
356

357
// overrideStructFromEnvWithPrefix recursively overrides struct fields with environment variables
358
304x
func overrideStructFromEnvWithPrefix(v interface{}, prefix string) {
359
304x
    val := reflect.ValueOf(v)
360
304x
    if val.Kind() == reflect.Ptr {
361
304x
        val = val.Elem()
362
304x
    }
363

364
304x
    if val.Kind() != reflect.Struct {
365
        return
366
    }
367

368
304x
    typ := val.Type()
369
304x
    for i := 0; i < val.NumField(); i++ {
370
2277x
        field := val.Field(i)
371
2277x
        fieldType := typ.Field(i)
372
2277x

373
2277x
        // Skip unexported fields
374
2277x
        if !field.CanSet() {
375
            continue
376
        }
377

378
        // Get the yaml tag for the field
379
2277x
        yamlTag := fieldType.Tag.Get("yaml")
380
2277x
        if yamlTag == "" || yamlTag == "-" {
381
            continue
382
        }
383

384
        // Convert yaml tag to environment variable name
385
2277x
        envKey := strings.ToUpper(strings.ReplaceAll(yamlTag, "-", "_"))
386
2277x
        if prefix != "" {
387
1833x
            envKey = prefix + "_" + envKey
388
1833x
        }
389

390
2277x
        switch field.Kind() {
391
851x
        case reflect.String:
392
851x
            if envVal := os.Getenv(envKey); envVal != "" {
393
170x
                field.SetString(envVal)
394
170x
            }
395
444x
        case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
396
444x
            if envVal := os.Getenv(envKey); envVal != "" {
397
9x
                if intVal, err := strconv.ParseInt(envVal, 10, 64); err == nil {
398
6x
                    field.SetInt(intVal)
399
6x
                }
400
            }
401
        case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
402
            if envVal := os.Getenv(envKey); envVal != "" {
403
                if uintVal, err := strconv.ParseUint(envVal, 10, 64); err == nil {
404
                    field.SetUint(uintVal)
405
                }
406
            }
407
74x
        case reflect.Float32, reflect.Float64:
408
74x
            if envVal := os.Getenv(envKey); envVal != "" {
409
3x
                if floatVal, err := strconv.ParseFloat(envVal, 64); err == nil {
410
2x
                    field.SetFloat(floatVal)
411
2x
                }
412
            }
413
312x
        case reflect.Bool:
414
312x
            if envVal := os.Getenv(envKey); envVal != "" {
415
9x
                if boolVal, err := strconv.ParseBool(envVal); err == nil {
416
8x
                    field.SetBool(boolVal)
417
8x
                }
418
            }
419
197x
        case reflect.Slice:
420
197x
            if envVal := os.Getenv(envKey); envVal != "" {
421
3x
                // Handle string slices (like CORS_ORIGINS)
422
3x
                if field.Type().Elem().Kind() == reflect.String {
423
3x
                    slice := strings.Split(envVal, ",")
424
3x
                    field.Set(reflect.ValueOf(slice))
425
3x
                }
426
            }
427
238x
        case reflect.Struct:
428
238x
            // Recursively process nested structs with the field name as prefix
429
238x
            if field.CanAddr() {
430
238x
                fieldPrefix := strings.ToUpper(strings.ReplaceAll(yamlTag, "-", "_"))
431
238x
                if prefix != "" {
432
90x
                    fieldPrefix = prefix + "_" + fieldPrefix
433
90x
                }
434
238x
                overrideStructFromEnvWithPrefix(field.Addr().Interface(), fieldPrefix)
435
            }
436
74x
        case reflect.Ptr:
437
74x
            // Handle pointer to struct
438
74x
            if !field.IsNil() && field.Elem().Kind() == reflect.Struct {
439
29x
                fieldPrefix := strings.ToUpper(strings.ReplaceAll(yamlTag, "-", "_"))
440
29x
                if prefix != "" {
441
                    fieldPrefix = prefix + "_" + fieldPrefix
442
                }
443
29x
                overrideStructFromEnvWithPrefix(field.Interface(), fieldPrefix)
444
            }
445
        }
446
    }
447
}
448

449
// loadConfigWithOverrides loads the config file with potential local overrides
450
32x
func loadConfigWithOverrides() (result0 *Config, err error) {
451
32x
    // Try to load from environment variable first
452
32x
    if envPath := os.Getenv("QUIZ_CONFIG_FILE"); envPath != "" {
453
32x
        config, err := loadConfigFromFile(envPath)
454
32x
        if err != nil {
455
3x
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to load config from %s: %w", envPath, err)
456
3x
        }
457
29x
        return config, nil
458
    }
459

460
    // If no environment variable is set, try default config.yaml
461
    return loadConfigFromFile("config.yaml")
462
}
463

464
// loadConfigFromFile loads configuration from a specific file
465
32x
func loadConfigFromFile(path string) (result0 *Config, err error) {
466
32x
    yamlFile, err := os.ReadFile(path)
467
32x
    if err != nil {
468
3x
        return nil, err
469
3x
    }
470

471
29x
    var config Config
472
29x
    if err := yaml.Unmarshal(yamlFile, &config); err != nil {
473
        return nil, err
474
    }
475

476
29x
    return &config, nil
477
}
478


			
quizapp internal database
72.6%
Statements
159/219
database.go
72.6%
159/219
quizapp internal database database.go
72.6%
Statements
159/219
1
// Package database provides database connection and migration functionality.
2
package database
3

4
import (
5
    "context"
6
    "database/sql"
7
    "errors"
8
    "fmt"
9
    "net/url"
10
    "os"
11
    "path/filepath"
12
    "strings"
13
    "sync"
14

15
    "quizapp/internal/config"
16
    "quizapp/internal/observability"
17
    contextutils "quizapp/internal/utils"
18

19
    // Import PostgreSQL driver for database/sql
20
    _ "github.com/lib/pq"
21

22
    // Add golang-migrate imports
23
    "github.com/golang-migrate/migrate/v4"
24
    _ "github.com/golang-migrate/migrate/v4/database/postgres" // required for golang-migrate postgres driver
25
    _ "github.com/golang-migrate/migrate/v4/source/file"       // required for golang-migrate file source
26

27
    // OpenTelemetry SQL instrumentation
28
    "go.nhat.io/otelsql"
29

30
    "go.opentelemetry.io/otel/attribute"
31
    semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
32
)
33

34
// Manager handles database operations with proper logging
35
type Manager struct {
36
    logger *observability.Logger
37
}
38

39
var (
40
    otelDriverNameCache string
41
    otelDriverOnce      sync.Once
42
    otelDriverErr       error
43
)
44

45
// NewManager creates a new database manager with the provided logger
46
12x
func NewManager(logger *observability.Logger) *Manager {
47
12x
    return &Manager{
48
12x
        logger: logger,
49
12x
    }
50
12x
}
51

52
// ErrTableAlreadyExists is returned when trying to create a table that already exists
53
var ErrTableAlreadyExists = errors.New("table already exists")
54

55
// DefaultDatabaseConfig returns the default database configuration
56
9x
func DefaultDatabaseConfig() config.DatabaseConfig {
57
9x
    config := config.DatabaseConfig{
58
9x
        MaxOpenConns:    25,
59
9x
        MaxIdleConns:    5,
60
9x
        ConnMaxLifetime: config.DatabaseConnMaxLifetime,
61
9x
    }
62
9x

63
9x
    // Check for TEST_DATABASE_URL first (for tests)
64
9x
    if testURL := os.Getenv("TEST_DATABASE_URL"); testURL != "" {
65
9x
        config.URL = testURL
66
9x
    }
67

68
9x
    return config
69
}
70

71
// InitDB initializes and returns a database connection with migrations
72
4x
func (dm *Manager) InitDB(databaseURL string) (result0 *sql.DB, err error) {
73
4x
    dbName := extractDatabaseName(databaseURL)
74
4x
    _, span := observability.TraceDatabaseFunction(context.Background(), "InitDB",
75
4x
        attribute.String("db.url", databaseURL),
76
4x
        attribute.String("db.name", dbName),
77
4x
        attribute.String("db.system", "postgresql"),
78
4x
        attribute.Bool("migrations.enabled", true),
79
4x
    )
80
4x
    defer observability.FinishSpan(span, &err)
81
4x
    config := DefaultDatabaseConfig()
82
4x
    config.URL = databaseURL
83
4x
    return dm.InitDBWithConfig(config)
84
4x
}
85

86
// InitDBWithConfig initializes and returns a database connection with migrations and custom config
87
4x
func (dm *Manager) InitDBWithConfig(config config.DatabaseConfig) (result0 *sql.DB, err error) {
88
4x
    dbName := extractDatabaseName(config.URL)
89
4x
    _, span := observability.TraceDatabaseFunction(context.Background(), "InitDBWithConfig",
90
4x
        attribute.String("db.url", config.URL),
91
4x
        attribute.String("db.name", dbName),
92
4x
        attribute.String("db.system", "postgresql"),
93
4x
        attribute.Bool("migrations.enabled", true),
94
4x
        attribute.Int("db.max_open_conns", config.MaxOpenConns),
95
4x
        attribute.Int("db.max_idle_conns", config.MaxIdleConns),
96
4x
        attribute.String("db.conn_max_lifetime", config.ConnMaxLifetime.String()),
97
4x
    )
98
4x
    defer observability.FinishSpan(span, &err)
99
4x
    db, err := dm.InitDBWithoutMigrations(config)
100
4x
    if err != nil {
101
1x
        return nil, err
102
1x
    }
103

104
2x
    if err := dm.RunMigrations(db); err != nil {
105
        return nil, err
106
    }
107

108
2x
    return db, nil
109
}
110

111
// extractDatabaseName extracts the database name from a PostgreSQL connection string
112
15x
func extractDatabaseName(databaseURL string) string {
113
15x
    // Try to parse as URL first
114
15x
    if u, err := url.Parse(databaseURL); err == nil && u.Path != "" {
115
14x
        // Remove leading slash and return the database name
116
14x
        dbName := strings.TrimPrefix(u.Path, "/")
117
14x
        if dbName != "" {
118
14x
            return dbName
119
14x
        }
120
    }
121

122
    // Fallback: try to extract from connection string format
123
    // postgres://user:pass@host:port/dbname?sslmode=disable
124
1x
    if strings.Contains(databaseURL, "/") {
125
        parts := strings.Split(databaseURL, "/")
126
        if len(parts) > 1 {
127
            // Get the last part and remove query parameters
128
            dbPart := parts[len(parts)-1]
129
            if idx := strings.Index(dbPart, "?"); idx != -1 {
130
                return dbPart[:idx]
131
            }
132
            return dbPart
133
        }
134
    }
135

136
    // Default fallback
137
1x
    return "quiz_db"
138
}
139

140
// InitDBWithoutMigrations initializes and returns a database connection without running migrations
141
9x
func (dm *Manager) InitDBWithoutMigrations(config config.DatabaseConfig) (result0 *sql.DB, err error) {
142
9x
    // Extract database name for OpenTelemetry tracing
143
9x
    ctx, span := observability.TraceDatabaseFunction(context.Background(), "InitDBWithoutMigrations",
144
9x
        attribute.String("database.url", config.URL),
145
9x
    )
146
9x
    defer observability.FinishSpan(span, &err)
147
9x

148
9x
    // Register OpenTelemetry SQL driver once per process and reuse the name
149
9x
    otelDriverOnce.Do(func() {
150
1x
        otelDriverNameCache, otelDriverErr = otelsql.Register("postgres",
151
1x
            otelsql.WithDatabaseName(extractDatabaseName(config.URL)),
152
1x
            otelsql.TraceQueryWithArgs(),
153
1x
            otelsql.WithSystem(semconv.DBSystemPostgreSQL),
154
1x
            otelsql.TraceRowsAffected(),
155
1x
        )
156
1x
    })
157
9x
    if otelDriverErr != nil {
158
        return nil, contextutils.WrapError(otelDriverErr, "failed to register otelsql driver")
159
    }
160

161
    // Connect to database using the instrumented driver
162
9x
    db, err := sql.Open(otelDriverNameCache, config.URL)
163
9x
    if err != nil {
164
        return nil, contextutils.WrapError(err, "failed to open database connection")
165
    }
166

167
    // Set connection pool settings
168
9x
    db.SetMaxOpenConns(config.MaxOpenConns)
169
9x
    db.SetMaxIdleConns(config.MaxIdleConns)
170
9x
    db.SetConnMaxLifetime(config.ConnMaxLifetime)
171
9x

172
9x
    // Test the connection
173
9x
    if err := db.Ping(); err != nil {
174
1x
        if closeErr := db.Close(); closeErr != nil {
175
            dm.logger.Error(ctx, "Failed to close database connection after ping failure", closeErr)
176
        }
177
1x
        return nil, contextutils.WrapError(err, "failed to ping database")
178
    }
179

180
8x
    dm.logger.Info(ctx, "Database connection established without migrations", map[string]interface{}{
181
8x
        "max_open_conns":    config.MaxOpenConns,
182
8x
        "max_idle_conns":    config.MaxIdleConns,
183
8x
        "conn_max_lifetime": config.ConnMaxLifetime,
184
8x
    })
185
8x

186
8x
    return db, nil
187
}
188

189
// RunMigrations executes the application SQL schema and any pending migrations
190
4x
func (dm *Manager) RunMigrations(db *sql.DB) (err error) {
191
4x
    _, span := observability.TraceDatabaseFunction(context.Background(), "RunMigrations",
192
4x
        attribute.String("db.system", "postgresql"),
193
4x
        attribute.String("migration.type", "application_schema"),
194
4x
    )
195
4x
    defer observability.FinishSpan(span, &err)
196
4x
    dm.logger.Info(context.Background(), "Starting database migrations...")
197
4x

198
4x
    // Run the main application schema first
199
4x
    if err := dm.runApplicationSchema(db); err != nil {
200
        return contextutils.WrapError(err, "failed to run application schema")
201
    }
202
4x
    dm.logger.Info(context.Background(), "Application schema applied successfully")
203
4x

204
4x
    // Run golang-migrate migrations if directory exists
205
4x
    if err := dm.runGolangMigrate(); err != nil {
206
        return contextutils.WrapError(err, "failed to run golang-migrate migrations")
207
    }
208

209
4x
    dm.logger.Info(context.Background(), "Database migrations completed successfully")
210
4x
    return nil
211
}
212

213
// runGolangMigrate runs migrations using golang-migrate from migrations
214
4x
func (dm *Manager) runGolangMigrate() (err error) {
215
4x
    migrationsPath, err := dm.GetMigrationsPath()
216
4x
    if err != nil {
217
        dm.logger.Error(context.Background(), "Could not find migrations path", err)
218
        return err // HARD FAIL if migrations path is not set
219
    }
220

221
4x
    _, span := observability.TraceDatabaseFunction(context.Background(), "runGolangMigrate",
222
4x
        attribute.String("db.system", "postgresql"),
223
4x
        attribute.String("migration.type", "golang_migrate"),
224
4x
        attribute.String("migration.path", migrationsPath),
225
4x
    )
226
4x
    defer observability.FinishSpan(span, &err)
227
4x

228
4x
    if migrationsPath == "" {
229
        err = errors.New("no golang-migrate migrations directory found")
230
        dm.logger.Error(context.Background(), "No golang-migrate migrations directory found, hard fail!", err)
231
        return err // HARD FAIL
232
    }
233

234
    // Check if migrations directory exists and has migration files
235
4x
    if _, statErr := os.Stat(migrationsPath); os.IsNotExist(statErr) {
236
        dm.logger.Error(context.Background(), "Migrations directory does not exist", statErr)
237
        err = statErr // HARD FAIL if directory does not exist
238
        return err
239
    }
240

241
    // Check if there are any migration files in the directory
242
4x
    files, err := os.ReadDir(migrationsPath)
243
4x
    if err != nil {
244
        dm.logger.Error(context.Background(), "Could not read migrations directory", err)
245
        return err // HARD FAIL
246
    }
247

248
    // Check if there are any .up.sql files
249
4x
    hasMigrationFiles := false
250
4x
    migrationFileCount := 0
251
4x
    for _, file := range files {
252
164x
        if !file.IsDir() && strings.HasSuffix(file.Name(), ".up.sql") {
253
80x
            hasMigrationFiles = true
254
80x
            migrationFileCount++
255
80x
        }
256
    }
257

258
4x
    span.SetAttributes(attribute.Int("migration.files.count", migrationFileCount))
259
4x

260
4x
    if !hasMigrationFiles {
261
        dm.logger.Info(context.Background(), fmt.Sprintf("No migration files found in %s. Skipping golang-migrate.", migrationsPath))
262
        return nil
263
    }
264

265
4x
    dbURL := os.Getenv("DATABASE_URL")
266
4x
    if dbURL == "" {
267
4x
        dbURL = os.Getenv("TEST_DATABASE_URL")
268
4x
    }
269
4x
    if dbURL == "" {
270
        err = errors.New("database_url or test_database_url must be set for migrations")
271
        return err
272
    }
273

274
    // Use file:// scheme with absolute path for golang-migrate
275
    // Convert to file:// URL format - use absolute path
276
4x
    migrationSourceURL := "file://" + filepath.ToSlash(migrationsPath)
277
4x

278
4x
    // Debug logging
279
4x
    dm.logger.Info(context.Background(), "Migration paths", map[string]interface{}{
280
4x
        "migrations_path": migrationsPath,
281
4x
        "source_url":      migrationSourceURL,
282
4x
        "db_url":          dbURL,
283
4x
    })
284
4x

285
4x
    m, err := migrate.New(
286
4x
        migrationSourceURL,
287
4x
        dbURL,
288
4x
    )
289
4x
    if err != nil {
290
        err = contextutils.WrapError(err, "failed to initialize golang-migrate")
291
        return err
292
    }
293
4x
    defer func() {
294
4x
        if _, closeErr := m.Close(); closeErr != nil {
295
            dm.logger.Error(context.Background(), "Error closing migration", closeErr)
296
        }
297
    }()
298

299
4x
    err = m.Up()
300
4x
    if err != nil && err != migrate.ErrNoChange {
301
        err = contextutils.WrapError(err, "golang-migrate up failed")
302
        return err
303
    }
304
4x
    if err == migrate.ErrNoChange {
305
4x
        dm.logger.Info(context.Background(), "No new golang-migrate migrations to apply.")
306
4x
    } else {
307
        dm.logger.Info(context.Background(), "golang-migrate migrations applied successfully.")
308
    }
309
4x
    return nil
310
}
311

312
// runApplicationSchema executes the main application schema.sql
313
5x
func (dm *Manager) runApplicationSchema(db *sql.DB) (err error) {
314
5x
    schemaPath, err := dm.getSchemaPath()
315
5x
    if err != nil {
316
        err = contextutils.WrapError(err, "failed to find schema file")
317
        return err
318
    }
319

320
5x
    _, span := observability.TraceDatabaseFunction(context.Background(), "runApplicationSchema",
321
5x
        attribute.String("db.system", "postgresql"),
322
5x
        attribute.String("migration.type", "application_schema"),
323
5x
        attribute.String("schema.path", schemaPath),
324
5x
    )
325
5x
    defer observability.FinishSpan(span, &err)
326
5x
    // Get the schema file path relative to the project root
327
5x
    schemaPath, err = dm.getSchemaPath()
328
5x
    if err != nil {
329
        err = contextutils.WrapError(err, "failed to find schema file")
330
        return err
331
    }
332

333
    // Read the schema file
334
5x
    schemaSQL, err := os.ReadFile(schemaPath)
335
5x
    if err != nil {
336
        err = contextutils.WrapError(err, "failed to read schema file")
337
        return err
338
    }
339

340
5x
    span.SetAttributes(attribute.Int("schema.file.size", len(schemaSQL)))
341
5x

342
5x
    // Parse SQL statements more carefully to handle comments and multi-line statements
343
5x
    statements := dm.parseSchemaStatements(string(schemaSQL))
344
5x

345
5x
    span.SetAttributes(attribute.Int("schema.statements.count", len(statements)))
346
5x

347
5x
    // Execute table creation statements first
348
5x
    var indexStatements []string
349
5x
    for _, statement := range statements {
350
430x
        statement = strings.TrimSpace(statement)
351
430x
        if statement == "" {
352
            continue
353
        }
354

355
        // Separate index creation from table creation
356
430x
        if strings.HasPrefix(strings.ToUpper(statement), "CREATE INDEX") {
357
280x
            indexStatements = append(indexStatements, statement)
358
280x
            continue
359
        }
360

361
150x
        _, execErr := db.Exec(statement)
362
150x
        if execErr != nil {
363
            // For backwards compatibility, ignore table exists errors
364
            if !dm.isTableExistsError(execErr) {
365
                err = contextutils.WrapErrorf(execErr, "failed to execute schema statement: %s", statement)
366
                return err
367
            }
368
        }
369
    }
370

371
5x
    span.SetAttributes(attribute.Int("schema.index_statements.count", len(indexStatements)))
372
5x

373
5x
    // Now execute index creation statements
374
5x
    for _, statement := range indexStatements {
375
280x
        _, execErr := db.Exec(statement)
376
280x
        if execErr != nil {
377
            // For backwards compatibility, ignore index exists errors
378
            if !dm.isTableExistsError(execErr) {
379
                err = contextutils.WrapErrorf(execErr, "failed to execute index statement: %s", statement)
380
                return err
381
            }
382
        }
383
    }
384

385
5x
    return nil
386
}
387

388
// getSchemaPath finds the schema.sql file relative to the project root
389
12x
func (dm *Manager) getSchemaPath() (result0 string, err error) {
390
12x
    _, span := observability.TraceDatabaseFunction(context.Background(), "getSchemaPath",
391
12x
        attribute.String("file.name", "schema.sql"),
392
12x
    )
393
12x
    defer observability.FinishSpan(span, &err)
394
12x
    // Start from the current directory and work up to find schema.sql
395
12x
    currentDir, err := os.Getwd()
396
12x
    if err != nil {
397
        return "", err
398
    }
399

400
12x
    span.SetAttributes(attribute.String("search.start_dir", currentDir))
401
12x

402
12x
    for {
403
48x
        schemaPath := filepath.Join(currentDir, "schema.sql")
404
48x
        if _, statErr := os.Stat(schemaPath); statErr == nil {
405
12x
            span.SetAttributes(attribute.String("schema.found_path", schemaPath))
406
12x
            return schemaPath, nil
407
12x
        }
408

409
        // Move up one directory
410
36x
        parentDir := filepath.Dir(currentDir)
411
36x
        if parentDir == currentDir {
412
            // We've reached the root directory
413
            span.SetAttributes(attribute.String("search.result", "not_found"))
414
            err = contextutils.ErrorWithContextf("schema.sql not found in any parent directory")
415
            return "", err
416
        }
417
36x
        currentDir = parentDir
418
    }
419
}
420

421
// parseSchemaStatements parses SQL statements from a schema file
422
6x
func (dm *Manager) parseSchemaStatements(schemaSQL string) []string {
423
6x
    _, span := observability.TraceDatabaseFunction(context.Background(), "parseSchemaStatements",
424
6x
        attribute.Int("input.length", len(schemaSQL)),
425
6x
    )
426
6x
    defer span.End()
427
6x

428
6x
    // Remove comments and normalize whitespace
429
6x
    lines := strings.Split(schemaSQL, "\n")
430
6x
    var cleanedLines []string
431
6x
    inComment := false
432
6x

433
6x
    for _, line := range lines {
434
2352x
        line = strings.TrimSpace(line)
435
2352x

436
2352x
        // Skip empty lines
437
2352x
        if line == "" {
438
6x
            continue
439
        }
440

441
        // Handle multi-line comments
442
2346x
        if strings.HasPrefix(line, "/*") {
443
            inComment = true
444
            continue
445
        }
446
2346x
        if strings.HasSuffix(line, "*/") {
447
            inComment = false
448
            continue
449
        }
450
2346x
        if inComment {
451
            continue
452
        }
453

454
        // Skip single-line comments
455
2346x
        if strings.HasPrefix(line, "--") {
456
270x
            continue
457
        }
458

459
        // Remove inline comments (comments that appear after SQL code)
460
2076x
        if commentIndex := strings.Index(line, "--"); commentIndex != -1 {
461
            line = strings.TrimSpace(line[:commentIndex])
462
        }
463

464
2076x
        cleanedLines = append(cleanedLines, line)
465
    }
466

467
    // Join lines and split by semicolon
468
6x
    cleanedSQL := strings.Join(cleanedLines, " ")
469
6x
    statements := strings.Split(cleanedSQL, ";")
470
6x

471
6x
    var result []string
472
6x
    for _, stmt := range statements {
473
522x
        stmt = strings.TrimSpace(stmt)
474
522x
        if stmt != "" {
475
516x
            result = append(result, stmt)
476
516x
        }
477
    }
478

479
6x
    span.SetAttributes(attribute.Int("statements.parsed", len(result)))
480
6x
    return result
481
}
482

483
// isTableExistsError checks if the error is due to a table already existing
484
1x
func (dm *Manager) isTableExistsError(err error) bool {
485
1x
    _, span := observability.TraceDatabaseFunction(context.Background(), "isTableExistsError")
486
1x
    defer span.End()
487
1x
    // Check for the sentinel error first
488
1x
    if errors.Is(err, ErrTableAlreadyExists) {
489
        return true
490
    }
491
    // Fallback to string matching for backwards compatibility
492
1x
    return strings.Contains(err.Error(), "already exists")
493
}
494

495
// GetMigrationsPath returns the path to the migrations directory
496
5x
func (dm *Manager) GetMigrationsPath() (result0 string, err error) {
497
5x
    _, span := observability.TraceDatabaseFunction(context.Background(), "GetMigrationsPath",
498
5x
        attribute.String("migration.dir.name", "migrations"),
499
5x
    )
500
5x
    defer observability.FinishSpan(span, &err)
501
5x
    // Start from the current directory and work up to find migrations directory
502
5x
    currentDir, err := os.Getwd()
503
5x
    if err != nil {
504
        return "", err
505
    }
506

507
5x
    span.SetAttributes(attribute.String("search.start_dir", currentDir))
508
5x

509
5x
    for {
510
15x
        migrationsPath := filepath.Join(currentDir, "migrations")
511
15x
        if _, statErr := os.Stat(migrationsPath); statErr == nil {
512
5x
            span.SetAttributes(attribute.String("migration.found_path", migrationsPath))
513
5x
            return migrationsPath, nil
514
5x
        }
515

516
        // Move up one directory
517
10x
        parentDir := filepath.Dir(currentDir)
518
10x
        if parentDir == currentDir {
519
            // We've reached the root directory
520
            span.SetAttributes(attribute.String("search.result", "not_found"))
521
            err = contextutils.ErrorWithContextf("migrations directory not found in any parent directory")
522
            return "", err
523
        }
524
10x
        currentDir = parentDir
525
    }
526
}
527


			
quizapp internal di
86.3%
Statements
82/95
container.go
86.3%
82/95
quizapp internal di container.go
86.3%
Statements
82/95
1
// Package di provides dependency injection container for managing service lifecycle and dependencies.
2
package di
3

4
import (
5
    "context"
6
    "database/sql"
7
    "sync"
8

9
    "quizapp/internal/config"
10
    "quizapp/internal/database"
11
    "quizapp/internal/observability"
12
    "quizapp/internal/services"
13
    contextutils "quizapp/internal/utils"
14
)
15

16
// ServiceContainerInterface defines the interface for service containers
17
type ServiceContainerInterface interface {
18
    GetService(name string) (interface{}, error)
19
    GetUserService() (services.UserServiceInterface, error)
20
    GetQuestionService() (services.QuestionServiceInterface, error)
21
    GetLearningService() (services.LearningServiceInterface, error)
22
    GetAIService() (services.AIServiceInterface, error)
23
    GetWorkerService() (services.WorkerServiceInterface, error)
24
    GetDailyQuestionService() (services.DailyQuestionServiceInterface, error)
25
    GetOAuthService() (*services.OAuthService, error)
26
    GetGenerationHintService() (services.GenerationHintServiceInterface, error)
27
    GetEmailService() (services.EmailServiceInterface, error)
28
    GetDatabase() *sql.DB
29
    GetConfig() *config.Config
30
    GetLogger() *observability.Logger
31
    Initialize(ctx context.Context) error
32
    Shutdown(ctx context.Context) error
33
    EnsureAdminUser(ctx context.Context) error
34
}
35

36
// ServiceContainer manages all service dependencies and lifecycle
37
type ServiceContainer struct {
38
    cfg           *config.Config
39
    logger        *observability.Logger
40
    dbManager     *database.Manager
41
    db            *sql.DB
42
    services      map[string]interface{}
43
    mu            sync.RWMutex
44
    shutdownFuncs []func(context.Context) error
45
}
46

47
// NewServiceContainer creates a new dependency injection container
48
9x
func NewServiceContainer(cfg *config.Config, logger *observability.Logger) *ServiceContainer {
49
9x
    return &ServiceContainer{
50
9x
        cfg:      cfg,
51
9x
        logger:   logger,
52
9x
        services: make(map[string]interface{}),
53
9x
    }
54
9x
}
55

56
// Initialize sets up all services and their dependencies
57
8x
func (sc *ServiceContainer) Initialize(ctx context.Context) error {
58
8x
    sc.mu.Lock()
59
8x
    defer sc.mu.Unlock()
60
8x

61
8x
    // Initialize database
62
8x
    sc.dbManager = database.NewManager(sc.logger)
63
8x
    db, err := sc.dbManager.InitDBWithConfig(sc.cfg.Database)
64
8x
    if err != nil {
65
1x
        return contextutils.WrapErrorf(err, "failed to initialize database")
66
1x
    }
67
7x
    sc.db = db
68
7x
    sc.shutdownFuncs = append(sc.shutdownFuncs, func(_ context.Context) error {
69
4x
        return db.Close()
70
4x
    })
71

72
    // Initialize core services
73
7x
    sc.initializeServices(ctx)
74
7x

75
7x
    // Startup lifecycle services
76
7x
    if err := sc.startupServices(ctx); err != nil {
77
        // Cleanup on failure
78
        _ = sc.cleanup(ctx)
79
        return contextutils.WrapErrorf(err, "failed to startup services")
80
    }
81

82
7x
    return nil
83
}
84

85
// GetService retrieves a service by name with type assertion
86
50x
func (sc *ServiceContainer) GetService(name string) (interface{}, error) {
87
50x
    sc.mu.RLock()
88
50x
    defer sc.mu.RUnlock()
89
50x

90
50x
    service, exists := sc.services[name]
91
50x
    if !exists {
92
2x
        return nil, contextutils.ErrorWithContextf("service %s not found", name)
93
2x
    }
94
48x
    return service, nil
95
}
96

97
// GetServiceAs performs type-safe service retrieval
98
46x
func GetServiceAs[T any](sc *ServiceContainer, name string) (T, error) {
99
46x
    var zero T
100
46x
    service, err := sc.GetService(name)
101
46x
    if err != nil {
102
1x
        return zero, err
103
1x
    }
104

105
45x
    typed, ok := service.(T)
106
45x
    if !ok {
107
1x
        return zero, contextutils.ErrorWithContextf("service %s is not of expected type %T", name, zero)
108
1x
    }
109
44x
    return typed, nil
110
}
111

112
// GetUserService returns the user service
113
18x
func (sc *ServiceContainer) GetUserService() (services.UserServiceInterface, error) {
114
18x
    return GetServiceAs[services.UserServiceInterface](sc, "user")
115
18x
}
116

117
// GetQuestionService returns the question service
118
13x
func (sc *ServiceContainer) GetQuestionService() (services.QuestionServiceInterface, error) {
119
13x
    return GetServiceAs[services.QuestionServiceInterface](sc, "question")
120
13x
}
121

122
// GetLearningService returns the learning service
123
3x
func (sc *ServiceContainer) GetLearningService() (services.LearningServiceInterface, error) {
124
3x
    return GetServiceAs[services.LearningServiceInterface](sc, "learning")
125
3x
}
126

127
// GetAIService returns the AI service
128
2x
func (sc *ServiceContainer) GetAIService() (services.AIServiceInterface, error) {
129
2x
    return GetServiceAs[services.AIServiceInterface](sc, "ai")
130
2x
}
131

132
// GetWorkerService returns the worker service
133
2x
func (sc *ServiceContainer) GetWorkerService() (services.WorkerServiceInterface, error) {
134
2x
    return GetServiceAs[services.WorkerServiceInterface](sc, "worker")
135
2x
}
136

137
// GetDailyQuestionService returns the daily question service
138
2x
func (sc *ServiceContainer) GetDailyQuestionService() (services.DailyQuestionServiceInterface, error) {
139
2x
    return GetServiceAs[services.DailyQuestionServiceInterface](sc, "daily_question")
140
2x
}
141

142
// GetOAuthService returns the OAuth service
143
2x
func (sc *ServiceContainer) GetOAuthService() (*services.OAuthService, error) {
144
2x
    service, err := sc.GetService("oauth")
145
2x
    if err != nil {
146
        return nil, err
147
    }
148
2x
    oauthService, ok := service.(*services.OAuthService)
149
2x
    if !ok {
150
        return nil, contextutils.ErrorWithContextf("oauth service has incorrect type")
151
    }
152
2x
    return oauthService, nil
153
}
154

155
// GetGenerationHintService returns the generation hint service
156
2x
func (sc *ServiceContainer) GetGenerationHintService() (services.GenerationHintServiceInterface, error) {
157
2x
    return GetServiceAs[services.GenerationHintServiceInterface](sc, "generation_hint")
158
2x
}
159

160
// GetEmailService returns the email service
161
2x
func (sc *ServiceContainer) GetEmailService() (services.EmailServiceInterface, error) {
162
2x
    return GetServiceAs[services.EmailServiceInterface](sc, "email")
163
2x
}
164

165
// GetDatabase returns the database instance
166
14x
func (sc *ServiceContainer) GetDatabase() *sql.DB {
167
14x
    return sc.db
168
14x
}
169

170
// GetConfig returns the configuration
171
13x
func (sc *ServiceContainer) GetConfig() *config.Config {
172
13x
    return sc.cfg
173
13x
}
174

175
// GetLogger returns the logger
176
13x
func (sc *ServiceContainer) GetLogger() *observability.Logger {
177
13x
    return sc.logger
178
13x
}
179

180
// Shutdown gracefully shuts down all services
181
4x
func (sc *ServiceContainer) Shutdown(ctx context.Context) error {
182
4x
    sc.mu.Lock()
183
4x
    defer sc.mu.Unlock()
184
4x

185
4x
    return sc.cleanup(ctx)
186
4x
}
187

188
// startupServices starts all services that implement the Lifecycle interface
189
7x
func (sc *ServiceContainer) startupServices(ctx context.Context) error {
190
7x
    // Check each service to see if it implements Lifecycle interface
191
7x
    for name, service := range sc.services {
192
63x
        if lifecycleService, ok := service.(interface{ Startup(context.Context) error }); ok {
193
            sc.logger.Info(ctx, "Starting service", map[string]interface{}{"service": name})
194
            if err := lifecycleService.Startup(ctx); err != nil {
195
                return contextutils.WrapErrorf(err, "failed to startup service %s", name)
196
            }
197
            sc.logger.Info(ctx, "Service started successfully", map[string]interface{}{"service": name})
198
        }
199
    }
200
7x
    return nil
201
}
202

203
// cleanup handles shutdown of all services
204
4x
func (sc *ServiceContainer) cleanup(ctx context.Context) error {
205
4x
    var errors []error
206
4x

207
4x
    // Shutdown lifecycle services first (in reverse order)
208
4x
    for name := range sc.services {
209
36x
        if lifecycleService, ok := sc.services[name].(interface{ Shutdown(context.Context) error }); ok {
210
4x
            sc.logger.Info(ctx, "Shutting down service", map[string]interface{}{"service": name})
211
4x
            if err := lifecycleService.Shutdown(ctx); err != nil {
212
                sc.logger.Error(ctx, "Failed to shutdown service", err, map[string]interface{}{"service": name})
213
                errors = append(errors, contextutils.WrapErrorf(err, "service %s shutdown failed", name))
214
            } else {
215
4x
                sc.logger.Info(ctx, "Service shutdown successfully", map[string]interface{}{"service": name})
216
4x
            }
217
        }
218
    }
219

220
    // Shutdown services in reverse order of initialization
221
4x
    for i := len(sc.shutdownFuncs) - 1; i >= 0; i-- {
222
8x
        if err := sc.shutdownFuncs[i](ctx); err != nil {
223
            errors = append(errors, err)
224
        }
225
    }
226

227
4x
    if len(errors) > 0 {
228
        return contextutils.ErrorWithContextf("shutdown errors: %v", errors)
229
    }
230
4x
    return nil
231
}
232

233
// initializeServices sets up all service dependencies
234
7x
func (sc *ServiceContainer) initializeServices(_ context.Context) {
235
7x
    // Core services that don't depend on other services
236
7x
    userService := services.NewUserServiceWithLogger(sc.db, sc.cfg, sc.logger)
237
7x
    sc.services["user"] = userService
238
7x

239
7x
    // Learning service depends on user service
240
7x
    learningService := services.NewLearningServiceWithLogger(sc.db, sc.cfg, sc.logger)
241
7x
    sc.services["learning"] = learningService
242
7x

243
7x
    // Question service depends on learning service
244
7x
    questionService := services.NewQuestionServiceWithLogger(sc.db, learningService, sc.cfg, sc.logger)
245
7x
    sc.services["question"] = questionService
246
7x

247
7x
    // Daily question service depends on question and learning services
248
7x
    dailyQuestionService := services.NewDailyQuestionService(sc.db, sc.logger, questionService, learningService)
249
7x
    sc.services["daily_question"] = dailyQuestionService
250
7x

251
7x
    // AI service
252
7x
    aiService := services.NewAIService(sc.cfg, sc.logger)
253
7x
    sc.services["ai"] = aiService
254
7x

255
7x
    // Worker service
256
7x
    workerService := services.NewWorkerServiceWithLogger(sc.db, sc.logger)
257
7x
    sc.services["worker"] = workerService
258
7x

259
7x
    // Generation hint service
260
7x
    generationHintService := services.NewGenerationHintService(sc.db, sc.logger)
261
7x
    sc.services["generation_hint"] = generationHintService
262
7x

263
7x
    // OAuth service
264
7x
    oauthService := services.NewOAuthServiceWithLogger(sc.cfg, sc.logger)
265
7x
    sc.services["oauth"] = oauthService
266
7x

267
7x
    // Email service
268
7x
    emailService := services.CreateEmailService(sc.cfg, sc.logger)
269
7x
    sc.services["email"] = emailService
270
7x

271
7x
    // Register shutdown functions
272
7x
    sc.shutdownFuncs = append(sc.shutdownFuncs,
273
7x
        func(_ context.Context) error { return nil }, // placeholder for future service shutdowns
274
    )
275
}
276

277
// EnsureAdminUser creates the admin user if it doesn't exist
278
3x
func (sc *ServiceContainer) EnsureAdminUser(ctx context.Context) error {
279
3x
    userService, err := sc.GetUserService()
280
3x
    if err != nil {
281
        return contextutils.WrapErrorf(err, "failed to get user service")
282
    }
283

284
3x
    return userService.EnsureAdminUserExists(ctx, sc.cfg.Server.AdminUsername, sc.cfg.Server.AdminPassword)
285
}
286


			
quizapp internal handlers
58.5%
Statements
1877/3206
admin_handler.go
37.3%
233/624
ai_fix_utils.go
77.6%
59/76
auth_handler.go
73.2%
213/291
authz.go
86.4%
19/22
convert.go
89.2%
181/203
daily_question_handler.go
59.3%
147/248
error_utils.go
65.9%
27/41
pagination.go
100.0%
20/20
quiz_handler.go
65.0%
290/446
route_listing.go
31.4%
11/35
router_factory.go
92.9%
169/182
session.go
100.0%
8/8
settings_handler.go
77.3%
140/181
test_mocks.go
30.6%
15/49
user_admin_handler.go
44.8%
181/404
worker_admin_handler.go
43.6%
164/376
quizapp internal handlers worker_admin_handler.go
37.3%
Statements
233/624
1
// Package handlers provides HTTP request handlers for the quiz application API.
2
package handlers
3

4
import (
5
    "context"
6
    "database/sql"
7
    "encoding/json"
8
    "errors"
9
    "html/template"
10
    "math"
11
    "net/http"
12
    "strconv"
13
    "strings"
14
    "time"
15

16
    "quizapp/internal/config"
17
    "quizapp/internal/models"
18
    "quizapp/internal/observability"
19
    "quizapp/internal/services"
20
    contextutils "quizapp/internal/utils"
21

22
    "github.com/gin-gonic/gin"
23
    "go.opentelemetry.io/otel/attribute"
24
)
25

26
// AdminHandler handles administrative HTTP requests and dashboard functionality
27
type AdminHandler struct {
28
    userService     services.UserServiceInterface
29
    questionService services.QuestionServiceInterface
30
    aiService       services.AIServiceInterface
31
    config          *config.Config
32
    templates       *template.Template
33
    learningService services.LearningServiceInterface
34
    workerService   services.WorkerServiceInterface
35
    logger          *observability.Logger
36
}
37

38
// NewAdminHandlerWithLogger creates a new AdminHandler with the provided services and logger.
39
11x
func NewAdminHandlerWithLogger(userService services.UserServiceInterface, questionService services.QuestionServiceInterface, aiService services.AIServiceInterface, cfg *config.Config, learningService services.LearningServiceInterface, workerService services.WorkerServiceInterface, logger *observability.Logger) *AdminHandler {
40
11x
    return &AdminHandler{
41
11x
        userService:     userService,
42
11x
        questionService: questionService,
43
11x
        aiService:       aiService,
44
11x
        config:          cfg,
45
11x
        templates:       nil,
46
11x
        learningService: learningService,
47
11x
        workerService:   workerService,
48
11x
        logger:          logger,
49
11x
    }
50
11x
}
51

52
// GetBackendAdminData returns the backend administration data as JSON
53
func (h *AdminHandler) GetBackendAdminData(c *gin.Context) {
54
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_backend_admin_data")
55
    defer observability.FinishSpan(span, nil)
56

57
    // Get all users for aggregate statistics
58
    users, err := h.userService.GetAllUsers(ctx)
59
    if err != nil {
60
        span.SetAttributes(attribute.String("error", err.Error()))
61
        HandleAppError(c, contextutils.WrapError(err, "failed to get users"))
62
        return
63
    }
64

65
    // Calculate aggregate user statistics
66
    userStats := calculateUserAggregateStats(ctx, users, h.learningService, h.logger)
67

68
    // Get question statistics
69
    questionStats, err := h.questionService.GetDetailedQuestionStats(ctx)
70
    if err != nil {
71
        h.logger.Warn(ctx, "Failed to get question stats", map[string]interface{}{"error": err.Error()})
72
        questionStats = make(map[string]interface{})
73
    }
74

75
    // Get worker health if available
76
    var workerHealth map[string]interface{}
77
    if h.workerService != nil {
78
        workerHealth, err = h.workerService.GetWorkerHealth(ctx)
79
        if err != nil {
80
            h.logger.Warn(ctx, "Failed to get worker health", map[string]interface{}{"error": err.Error()})
81
            workerHealth = map[string]interface{}{
82
                "error": "Failed to get worker health",
83
            }
84
        }
85
    }
86

87
    // Get AI concurrency stats
88
    aiStatsStruct := h.aiService.GetConcurrencyStats()
89
    aiConcurrencyStats := map[string]interface{}{
90
        "active_requests":   aiStatsStruct.ActiveRequests,
91
        "max_concurrent":    aiStatsStruct.MaxConcurrent,
92
        "queued_requests":   aiStatsStruct.QueuedRequests,
93
        "total_requests":    aiStatsStruct.TotalRequests,
94
        "user_active_count": aiStatsStruct.UserActiveCount,
95
        "max_per_user":      aiStatsStruct.MaxPerUser,
96
    }
97

98
    data := gin.H{
99
        "user_stats":           userStats,
100
        "question_stats":       questionStats,
101
        "worker_health":        workerHealth,
102
        "ai_concurrency_stats": aiConcurrencyStats,
103
        "worker_port":          h.config.Server.WorkerPort,
104
        "worker_base_url":      h.config.Server.WorkerBaseURL,
105
    }
106

107
    c.JSON(http.StatusOK, data)
108
}
109

110
// GetBackendAdminPage renders the backend administration dashboard
111
1x
func (h *AdminHandler) GetBackendAdminPage(c *gin.Context) {
112
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_backend_admin_page")
113
1x
    defer observability.FinishSpan(span, nil)
114
1x

115
1x
    // Get all users with progress and question stats
116
1x
    users, err := h.userService.GetAllUsers(ctx)
117
1x
    if err != nil {
118
        span.SetAttributes(attribute.String("error", err.Error()))
119
        HandleAppError(c, contextutils.WrapError(err, "failed to get users"))
120
        return
121
    }
122

123
1x
    type UserWithProgress struct {
124
1x
        User               models.User
125
1x
        Progress           *models.UserProgress
126
1x
        QuestionStats      *services.UserQuestionStats
127
1x
        UserQuestionCounts map[string]interface{}
128
1x
    }
129
1x

130
1x
    var usersWithProgress []UserWithProgress
131
1x
    for _, user := range users {
132
1x
        progress, err := h.learningService.GetUserProgress(ctx, user.ID)
133
1x
        if err != nil {
134
            h.logger.Warn(ctx, "Failed to get progress for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
135
            progress = &models.UserProgress{
136
                CurrentLevel:   "A1",
137
                TotalQuestions: 0,
138
                CorrectAnswers: 0,
139
                AccuracyRate:   0,
140
            }
141
        }
142

143
1x
        questionStats, err := h.learningService.GetUserQuestionStats(ctx, user.ID)
144
1x
        if err != nil {
145
            h.logger.Warn(ctx, "Failed to get question stats for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
146
            questionStats = &services.UserQuestionStats{
147
                UserID:        user.ID,
148
                TotalAnswered: 0,
149
            }
150
        }
151

152
        // Get per-user question counts by type and level
153
1x
        userQuestionCounts := make(map[string]interface{})
154
1x

155
1x
        // Use the available stats from UserQuestionStats
156
1x
        if questionStats != nil {
157
1x
            userQuestionCounts["total_answered"] = questionStats.TotalAnswered
158
1x
            userQuestionCounts["answered_by_type"] = questionStats.AnsweredByType
159
1x
            userQuestionCounts["answered_by_level"] = questionStats.AnsweredByLevel
160
1x
            userQuestionCounts["accuracy_by_type"] = questionStats.AccuracyByType
161
1x
            userQuestionCounts["accuracy_by_level"] = questionStats.AccuracyByLevel
162
1x
            userQuestionCounts["available_by_type"] = questionStats.AvailableByType
163
1x
            userQuestionCounts["available_by_level"] = questionStats.AvailableByLevel
164
1x
        }
165

166
1x
        usersWithProgress = append(usersWithProgress, UserWithProgress{
167
1x
            User:               user,
168
1x
            Progress:           progress,
169
1x
            QuestionStats:      questionStats,
170
1x
            UserQuestionCounts: userQuestionCounts,
171
1x
        })
172
    }
173

174
    // Get question statistics
175
1x
    questionStats, err := h.questionService.GetDetailedQuestionStats(ctx)
176
1x
    if err != nil {
177
        h.logger.Warn(ctx, "Failed to get question stats", map[string]interface{}{"error": err.Error()})
178
        questionStats = make(map[string]interface{})
179
    }
180

181
    // Get worker health if available
182
1x
    var workerHealth map[string]interface{}
183
1x
    if h.workerService != nil {
184
1x
        workerHealth, err = h.workerService.GetWorkerHealth(ctx)
185
1x
        if err != nil {
186
            h.logger.Warn(ctx, "Failed to get worker health", map[string]interface{}{"error": err.Error()})
187
            workerHealth = map[string]interface{}{
188
                "error": "Failed to get worker health",
189
            }
190
        }
191
    }
192

193
    // Get AI concurrency stats
194
1x
    aiStatsStruct := h.aiService.GetConcurrencyStats()
195
1x
    aiConcurrencyStats := map[string]interface{}{
196
1x
        "active_requests":   aiStatsStruct.ActiveRequests,
197
1x
        "max_concurrent":    aiStatsStruct.MaxConcurrent,
198
1x
        "queued_requests":   aiStatsStruct.QueuedRequests,
199
1x
        "total_requests":    aiStatsStruct.TotalRequests,
200
1x
        "user_active_count": aiStatsStruct.UserActiveCount,
201
1x
        "max_per_user":      aiStatsStruct.MaxPerUser,
202
1x
    }
203
1x

204
1x
    data := gin.H{
205
1x
        "Title":              "Backend Administration",
206
1x
        "Users":              usersWithProgress,
207
1x
        "QuestionStats":      questionStats,
208
1x
        "WorkerHealth":       workerHealth,
209
1x
        "AIConcurrencyStats": aiConcurrencyStats,
210
1x
        "IsBackend":          true,
211
1x
        "WorkerPort":         h.config.Server.WorkerPort,
212
1x
        "CurrentPage":        "backend_admin",
213
1x
        "WorkerBaseURL":      h.config.Server.WorkerBaseURL,
214
1x
    }
215
1x

216
1x
    // Try to render template, fallback to JSON if template fails
217
1x
    if h.templates != nil {
218
        // Add no-cache headers
219
        c.Header("Content-Type", "text/html; charset=utf-8")
220
        c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
221
        c.Header("Pragma", "no-cache")
222
        c.Header("Expires", "0")
223

224
        if err := h.templates.ExecuteTemplate(c.Writer, "backend_admin.html", data); err != nil {
225
            h.logger.Error(ctx, "Template execution failed", err, map[string]interface{}{})
226
            HandleAppError(c, contextutils.WrapError(err, "failed to render template"))
227
            return
228
        }
229
1x
    } else {
230
1x
        c.JSON(http.StatusOK, data)
231
1x
    }
232
}
233

234
// UserData represents user information combined with their progress data
235
type UserData struct {
236
    User     models.User
237
    Progress *models.UserProgress
238
}
239

240
// UserDataWithQuestions represents user information with questions and responses
241
type UserDataWithQuestions struct {
242
    User            models.User
243
    Progress        *models.UserProgress
244
    QuestionStats   *services.UserQuestionStats
245
    TotalQuestions  int
246
    TotalResponses  int
247
    RecentQuestions []string
248
    Questions       []*services.QuestionWithStats // Actual question objects with stats
249
}
250

251
// ReportedQuestionsData represents the structure for reported questions page data
252
type ReportedQuestionsData struct {
253
    Users             []UserDataWithQuestions
254
    ReportedQuestions []*services.ReportedQuestionWithUser
255
}
256

257
// ShowDatazPage - Removed: Use frontend admin interface instead
258

259
// MarkQuestionAsFixed marks a reported question as fixed and puts it back in rotation
260
1x
func (h *AdminHandler) MarkQuestionAsFixed(c *gin.Context) {
261
1x
    questionIDStr := c.Param("id")
262
1x
    questionID, err := strconv.Atoi(questionIDStr)
263
1x
    if err != nil {
264
        HandleAppError(c, contextutils.ErrInvalidFormat)
265
        return
266
    }
267

268
1x
    if err := h.questionService.MarkQuestionAsFixed(c.Request.Context(), questionID); err != nil {
269
        h.logger.Error(c.Request.Context(), "Failed to mark question as fixed", err, map[string]interface{}{"question_id": questionID})
270

271
        // Check if the error is due to question not found
272
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
273
            HandleAppError(c, contextutils.ErrQuestionNotFound)
274
            return
275
        }
276

277
        HandleAppError(c, contextutils.WrapError(err, "failed to mark question as fixed"))
278
        return
279
    }
280

281
1x
    c.JSON(http.StatusOK, gin.H{"message": "Question marked as fixed successfully"})
282
}
283

284
// UpdateQuestion updates a question's content, correct answer, and explanation
285
4x
func (h *AdminHandler) UpdateQuestion(c *gin.Context) {
286
4x
    questionIDStr := c.Param("id")
287
4x
    questionID, err := strconv.Atoi(questionIDStr)
288
4x
    if err != nil {
289
        HandleAppError(c, contextutils.ErrInvalidFormat)
290
        return
291
    }
292

293
4x
    var req struct {
294
4x
        Content       map[string]interface{} `json:"content" binding:"required"`
295
4x
        CorrectAnswer int                    `json:"correct_answer" binding:"gte=0,lte=3"`
296
4x
        Explanation   string                 `json:"explanation" binding:"required"`
297
4x
    }
298
4x

299
4x
    if err := c.ShouldBindJSON(&req); err != nil {
300
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
301
1x
            contextutils.ErrorCodeInvalidInput,
302
1x
            contextutils.SeverityWarn,
303
1x
            "Invalid request format",
304
1x
            "",
305
1x
            err,
306
1x
        ))
307
1x
        return
308
1x
    }
309

310
    // Sanitize incoming content to avoid nested `content.content` and duplicated fields.
311
3x
    content := req.Content
312
3x
    for {
313
4x
        if inner, ok := content["content"]; ok {
314
1x
            if innerMap, ok2 := inner.(map[string]interface{}); ok2 {
315
1x
                content = innerMap
316
1x
                continue
317
            }
318
        }
319
3x
        break
320
    }
321

322
    // Remove duplicate top-level keys from the content payload if present.
323
    // Defensive cleanup while migrating to strict OpenAPI validation.
324
3x
    delete(content, "correct_answer")
325
3x
    delete(content, "explanation")
326
3x
    delete(content, "change_reason")
327
3x

328
3x
    // Ensure options is not nil (convert null -> empty slice)
329
3x
    if opts, exists := content["options"]; !exists || opts == nil {
330
        content["options"] = []string{}
331
    }
332

333
3x
    if err := h.questionService.UpdateQuestion(c.Request.Context(), questionID, content, req.CorrectAnswer, req.Explanation); err != nil {
334
        h.logger.Error(c.Request.Context(), "Failed to update question", err, map[string]interface{}{"question_id": questionID})
335

336
        // Check if the error is due to question not found
337
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
338
            HandleAppError(c, contextutils.ErrQuestionNotFound)
339
            return
340
        }
341

342
        HandleAppError(c, contextutils.WrapError(err, "failed to update question"))
343
        return
344
    }
345

346
    // If requested, mark the question as fixed and clear reports
347
3x
    if strings.ToLower(c.Query("mark_fixed")) == "true" {
348
2x
        ctx := c.Request.Context()
349
2x
        // Mark as fixed (sets status to active)
350
2x
        if err := h.questionService.MarkQuestionAsFixed(ctx, questionID); err != nil {
351
            h.logger.Error(ctx, "Failed to mark question as fixed after update", err, map[string]interface{}{"question_id": questionID})
352
            HandleAppError(c, contextutils.WrapError(err, "failed to mark question as fixed"))
353
            return
354
        }
355

356
        // Clear question reports
357
2x
        db := h.questionService.DB()
358
2x
        if _, err := db.ExecContext(ctx, `DELETE FROM question_reports WHERE question_id = $1`, questionID); err != nil {
359
            h.logger.Warn(ctx, "Failed to clear question reports", map[string]interface{}{"question_id": questionID, "error": err.Error()})
360
        }
361
    }
362

363
3x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "Question updated successfully"})
364
}
365

366
// FixQuestionWithAI uses AI to suggest fixes for a problematic question
367
3x
func (h *AdminHandler) FixQuestionWithAI(c *gin.Context) {
368
3x
    questionIDStr := c.Param("id")
369
3x
    questionID, err := strconv.Atoi(questionIDStr)
370
3x
    if err != nil {
371
        HandleAppError(c, contextutils.ErrInvalidFormat)
372
        return
373
    }
374

375
    // Get the original question
376
3x
    question, err := h.questionService.GetQuestionByID(c.Request.Context(), questionID)
377
3x
    if err != nil {
378
1x
        h.logger.Error(c.Request.Context(), "Failed to get question", err, map[string]interface{}{"question_id": questionID})
379
1x

380
1x
        // Check if the error is due to question not found
381
1x
        if errors.Is(err, sql.ErrNoRows) {
382
1x
            HandleAppError(c, contextutils.ErrQuestionNotFound)
383
1x
            return
384
1x
        }
385

386
        HandleAppError(c, contextutils.WrapError(err, "failed to get question"))
387
        return
388
    }
389

390
    // Find reporter(s) and choose a configured AI provider/model from the reporting user(s)
391
2x
    ctx := c.Request.Context()
392
2x
    db := h.questionService.DB()
393
2x
    rows, err := db.QueryContext(ctx, `SELECT u.id, u.username, u.ai_provider, u.ai_model, qr.report_reason FROM question_reports qr JOIN users u ON qr.reported_by_user_id = u.id WHERE qr.question_id = $1 ORDER BY qr.created_at ASC`, questionID)
394
2x
    if err != nil {
395
        h.logger.Error(ctx, "Failed to query question reports", err, map[string]interface{}{"question_id": questionID})
396
        HandleAppError(c, contextutils.WrapError(err, "failed to get report details"))
397
        return
398
    }
399
2x
    if err := rows.Err(); err != nil {
400
        h.logger.Warn(ctx, "rows iteration error before defer", map[string]interface{}{"error": err.Error(), "question_id": questionID})
401
    }
402
2x
    defer func() {
403
2x
        if err := rows.Close(); err != nil {
404
            h.logger.Warn(ctx, "Failed to close report rows", map[string]interface{}{"error": err.Error(), "question_id": questionID})
405
        }
406
    }()
407

408
2x
    var reporterID int
409
2x
    var reporterUsername string
410
2x
    var reporterProvider sql.NullString
411
2x
    var reporterModel sql.NullString
412
2x
    var singleReason sql.NullString
413
2x
    foundProvider := false
414
2x

415
2x
    for rows.Next() {
416
        var uid int
417
        var uname string
418
        var prov sql.NullString
419
        var mod sql.NullString
420
        var reason sql.NullString
421
        if err := rows.Scan(&uid, &uname, &prov, &mod, &reason); err != nil {
422
            h.logger.Warn(ctx, "Failed to scan report row", map[string]interface{}{"error": err.Error(), "question_id": questionID})
423
            continue
424
        }
425
        // Prefer the first reporter that has an AI provider+model configured
426
        if prov.Valid && prov.String != "" && mod.Valid && mod.String != "" {
427
            reporterID = uid
428
            reporterUsername = uname
429
            reporterProvider = prov
430
            reporterModel = mod
431
            singleReason = reason
432
            foundProvider = true
433
            break
434
        }
435
        // Keep the first reporter as fallback (no provider)
436
        if reporterID == 0 {
437
            reporterID = uid
438
            reporterUsername = uname
439
            reporterProvider = prov
440
            reporterModel = mod
441
            singleReason = reason
442
        }
443
    }
444

445
2x
    if !foundProvider {
446
2x
        // If no reporting user has AI configured, fall back to admin user's AI settings or global default provider
447
2x
        h.logger.Info(ctx, "No reporting user has AI configured; attempting fallback to admin or global provider", map[string]interface{}{"question_id": questionID})
448
2x

449
2x
        // Try to get current admin user from context/session
450
2x
        var adminUserID int
451
2x
        if uid, err := GetCurrentUserID(c); err == nil {
452
2x
            adminUserID = uid
453
2x
        }
454

455
        // Try admin user's configured provider/model
456
2x
        if adminUserID != 0 {
457
2x
            adminUser, err := h.userService.GetUserByID(ctx, adminUserID)
458
2x
            if err == nil && adminUser != nil && adminUser.AIProvider.Valid && adminUser.AIProvider.String != "" && adminUser.AIModel.Valid && adminUser.AIModel.String != "" {
459
                reporterID = adminUser.ID
460
                reporterUsername = adminUser.Username
461
                reporterProvider = adminUser.AIProvider
462
                reporterModel = adminUser.AIModel
463
                foundProvider = true
464
                h.logger.Info(ctx, "Falling back to admin user's AI provider", map[string]interface{}{"admin_id": adminUserID, "provider": adminUser.AIProvider.String, "model": adminUser.AIModel.String})
465
            }
466
        }
467

468
        // If still not found, try global config first provider
469
2x
        if !foundProvider && h.config != nil && len(h.config.Providers) > 0 {
470
2x
            p := h.config.Providers[0]
471
2x
            if len(p.Models) > 0 {
472
2x
                // Use first provider and model from global config
473
2x
                reporterProvider = sql.NullString{String: p.Code, Valid: true}
474
2x
                reporterModel = sql.NullString{String: p.Models[0].Code, Valid: true}
475
2x
                reporterUsername = "system"
476
2x
                foundProvider = true
477
2x
                h.logger.Info(ctx, "Falling back to global configured AI provider", map[string]interface{}{"provider": p.Code, "model": p.Models[0].Code})
478
2x
            }
479
        }
480

481
2x
        if !foundProvider {
482
            h.logger.Warn(ctx, "No AI provider configured for reporting users and no fallback available", map[string]interface{}{"question_id": questionID})
483
            HandleAppError(c, contextutils.ErrAIConfigInvalid)
484
            return
485
        }
486
    }
487

488
    // Get saved API key for the reporter's configured provider
489
2x
    savedKey, _ := h.userService.GetUserAPIKey(ctx, reporterID, reporterProvider.String)
490
2x

491
2x
    userCfg := &services.UserAIConfig{
492
2x
        Provider: reporterProvider.String,
493
2x
        Model:    reporterModel.String,
494
2x
        APIKey:   savedKey,
495
2x
        Username: reporterUsername,
496
2x
    }
497
2x

498
2x
    // Build AI chat request with question details and report reasons
499
2x
    // Use the template manager to render a structured prompt
500
2x
    // Prepare template data
501
2x
    questionContentJSON, _ := question.MarshalContentToJSON()
502
2x
    // Resolve schema for prompt; fail if none
503
2x
    schema, err := services.GetFixSchema(question.Type)
504
2x
    if err != nil {
505
        h.logger.Error(ctx, "No schema available for question type", err, map[string]interface{}{"question_id": questionID, "type": question.Type})
506
        HandleAppError(c, contextutils.ErrAIConfigInvalid)
507
        return
508
    }
509

510
    // Read optional additional_context from POST body JSON
511
2x
    var body struct {
512
2x
        AdditionalContext string `json:"additional_context"`
513
2x
    }
514
2x
    _ = c.BindJSON(&body) // ignore error; body may be empty
515
2x

516
2x
    tmplData := services.AITemplateData{
517
2x
        CurrentQuestionJSON: questionContentJSON,
518
2x
        ExampleContent:      "", // will be filled below if example available
519
2x
        SchemaForPrompt:     schema,
520
2x
        ReportReasons:       []string{},
521
2x
        AdditionalContext:   body.AdditionalContext,
522
2x
    }
523
2x
    if singleReason.Valid {
524
        tmplData.ReportReasons = []string{singleReason.String}
525
    }
526
    // Load example for this question type if available
527
2x
    if ex, err := h.aiService.TemplateManager().LoadExample(string(question.Type)); err == nil {
528
2x
        tmplData.ExampleContent = ex
529
2x
    }
530

531
2x
    prompt, err := h.aiService.TemplateManager().RenderTemplate(services.AIFixPromptTemplate, tmplData)
532
2x
    if err != nil {
533
        h.logger.Error(ctx, "Failed to render AI fix prompt", err, map[string]interface{}{"question_id": questionID})
534
        HandleAppError(c, contextutils.WrapError(err, "failed to build AI prompt"))
535
        return
536
    }
537

538
    // Use schema as grammar for providers that support it
539
2x
    supportsGrammar := h.aiService.SupportsGrammarField(userCfg.Provider)
540
2x
    var grammar string
541
2x
    if supportsGrammar {
542
2x
        grammar, err = services.GetFixSchema(question.Type)
543
2x
        if err != nil {
544
            h.logger.Error(ctx, "No grammar schema available for question type", err, map[string]interface{}{"question_id": questionID, "type": question.Type})
545
            HandleAppError(c, contextutils.ErrAIConfigInvalid)
546
            return
547
        }
548
    } else {
549
        grammar = ""
550
    }
551

552
    // Call AI service with constructed prompt and grammar
553
2x
    respStr, err := h.aiService.CallWithPrompt(ctx, userCfg, prompt, grammar)
554
2x
    if err != nil {
555
        h.logger.Error(ctx, "AI service call failed", err, map[string]interface{}{"question_id": questionID, "provider": userCfg.Provider})
556
        HandleAppError(c, contextutils.WrapError(err, "AI service error"))
557
        return
558
    }
559

560
    // Attempt to parse AI response as JSON (and try to recover JSON substring if necessary)
561
2x
    var aiResp map[string]interface{}
562
2x
    if err := json.Unmarshal([]byte(respStr), &aiResp); err != nil {
563
        start := strings.Index(respStr, "{")
564
        end := strings.LastIndex(respStr, "}")
565
        if start >= 0 && end > start {
566
            candidate := respStr[start : end+1]
567
            if err2 := json.Unmarshal([]byte(candidate), &aiResp); err2 != nil {
568
                h.logger.Error(ctx, "Failed to parse AI response as JSON", err2, map[string]interface{}{"question_id": questionID})
569
                HandleAppError(c, contextutils.ErrAIResponseInvalid)
570
                return
571
            }
572
        } else {
573
            h.logger.Error(ctx, "AI did not return JSON", nil, map[string]interface{}{"question_id": questionID})
574
            HandleAppError(c, contextutils.ErrAIResponseInvalid)
575
            return
576
        }
577
    }
578

579
    // Start from the original question map so required top-level fields are preserved
580
2x
    originalMap := map[string]interface{}{}
581
2x
    if b, err := json.Marshal(question); err == nil {
582
2x
        _ = json.Unmarshal(b, &originalMap)
583
2x
    }
584

585
    // Use helper to merge and normalize AI suggestion into original map
586
2x
    suggestion := MergeAISuggestion(originalMap, aiResp)
587
2x
    // Attach admin-provided additional context into suggestion metadata so frontend can display it
588
2x
    if body.AdditionalContext != "" {
589
        suggestion["additional_context"] = body.AdditionalContext
590
    }
591

592
    // If query param apply=true present, apply suggestion directly and mark fixed
593
2x
    if strings.ToLower(c.Query("apply")) == "true" {
594
        // Build update payload: use merged content
595
        updateContent := suggestion["content"].(map[string]interface{})
596

597
        // Extract correct_answer as int (support float64 from JSON)
598
        correctAnswer := 0
599
        if ca, ok := updateContent["correct_answer"]; ok {
600
            switch v := ca.(type) {
601
            case float64:
602
                correctAnswer = int(v)
603
            case int:
604
                correctAnswer = v
605
            }
606
        }
607

608
        explanation := ""
609
        if ex, ok := updateContent["explanation"].(string); ok {
610
            explanation = ex
611
        }
612

613
        if err := h.questionService.UpdateQuestion(c.Request.Context(), questionID, updateContent, correctAnswer, explanation); err != nil {
614
            h.logger.Error(c.Request.Context(), "Failed to update question with AI suggestion", err, map[string]interface{}{"question_id": questionID})
615
            HandleAppError(c, contextutils.WrapError(err, "failed to apply suggestion"))
616
            return
617
        }
618

619
        if err := h.questionService.MarkQuestionAsFixed(c.Request.Context(), questionID); err != nil {
620
            h.logger.Warn(c.Request.Context(), "Failed to mark question as fixed after applying suggestion", map[string]interface{}{"question_id": questionID, "error": err.Error()})
621
        }
622
        db := h.questionService.DB()
623
        if _, err := db.ExecContext(c.Request.Context(), `DELETE FROM question_reports WHERE question_id = $1`, questionID); err != nil {
624
            h.logger.Warn(c.Request.Context(), "Failed to clear question reports after applying suggestion", map[string]interface{}{"question_id": questionID, "error": err.Error()})
625
        }
626

627
        c.JSON(http.StatusOK, gin.H{"success": true, "message": "Suggestion applied"})
628
        return
629
    }
630

631
    // Return original question and merged AI suggestion for frontend review
632
2x
    c.JSON(http.StatusOK, gin.H{
633
2x
        "original":   question,
634
2x
        "suggestion": suggestion,
635
2x
    })
636
}
637

638
// ServeDatazJS - Removed: Use frontend admin interface instead
639

640
// GetAIConcurrencyStats returns AI service concurrency metrics
641
func (h *AdminHandler) GetAIConcurrencyStats(c *gin.Context) {
642
    // Get stats from the local AI service instance
643
    stats := h.aiService.GetConcurrencyStats()
644
    c.JSON(http.StatusOK, gin.H{
645
        "ai_concurrency": stats,
646
    })
647
}
648

649
// ClearUserData removes all user activity data but keeps the users themselves
650
1x
func (h *AdminHandler) ClearUserData(c *gin.Context) {
651
1x
    err := h.userService.ClearUserData(c.Request.Context())
652
1x
    if err != nil {
653
        h.logger.Error(c.Request.Context(), "Failed to clear user data", err, map[string]interface{}{})
654
        HandleAppError(c, contextutils.WrapError(err, "failed to clear user data"))
655
        return
656
    }
657

658
1x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "User data cleared successfully (users preserved)"})
659
}
660

661
// ClearDatabase completely resets the database to an empty state
662
1x
func (h *AdminHandler) ClearDatabase(c *gin.Context) {
663
1x
    err := h.userService.ResetDatabase(c.Request.Context())
664
1x
    if err != nil {
665
        h.logger.Error(c.Request.Context(), "Failed to clear database", err, map[string]interface{}{})
666
        HandleAppError(c, contextutils.WrapError(err, "failed to clear database"))
667
        return
668
    }
669

670
1x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "Database cleared successfully"})
671
}
672

673
// GetQuestion returns a single question by ID for editing
674
2x
func (h *AdminHandler) GetQuestion(c *gin.Context) {
675
2x
    questionIDStr := c.Param("id")
676
2x
    questionID, err := strconv.Atoi(questionIDStr)
677
2x
    if err != nil {
678
        HandleAppError(c, contextutils.ErrInvalidFormat)
679
        return
680
    }
681

682
2x
    question, err := h.questionService.GetQuestionByID(c.Request.Context(), questionID)
683
2x
    if err != nil {
684
1x
        h.logger.Error(c.Request.Context(), "Failed to get question", err, map[string]interface{}{"question_id": questionID})
685
1x
        HandleAppError(c, contextutils.ErrQuestionNotFound)
686
1x
        return
687
1x
    }
688

689
1x
    c.JSON(http.StatusOK, question)
690
}
691

692
// GetUsersForQuestion returns the users assigned to a question
693
2x
func (h *AdminHandler) GetUsersForQuestion(c *gin.Context) {
694
2x
    questionIDStr := c.Param("id")
695
2x
    questionID, err := strconv.Atoi(questionIDStr)
696
2x
    if err != nil {
697
        HandleAppError(c, contextutils.ErrInvalidFormat)
698
        return
699
    }
700

701
2x
    users, totalCount, err := h.questionService.GetUsersForQuestion(c.Request.Context(), questionID)
702
2x
    if err != nil {
703
        h.logger.Error(c.Request.Context(), "Failed to get users for question", err, map[string]interface{}{"question_id": questionID})
704
        HandleAppError(c, contextutils.WrapError(err, "failed to get users for question"))
705
        return
706
    }
707

708
2x
    c.JSON(http.StatusOK, gin.H{
709
2x
        "users":       users,
710
2x
        "total_count": totalCount,
711
2x
    })
712
}
713

714
// AssignUsersToQuestion assigns multiple users to a question
715
2x
func (h *AdminHandler) AssignUsersToQuestion(c *gin.Context) {
716
2x
    questionIDStr := c.Param("id")
717
2x
    questionID, err := strconv.Atoi(questionIDStr)
718
2x
    if err != nil {
719
        HandleAppError(c, contextutils.ErrInvalidFormat)
720
        return
721
    }
722

723
2x
    var request struct {
724
2x
        UserIDs []int `json:"user_ids" binding:"required"`
725
2x
    }
726
2x

727
2x
    if err := c.ShouldBindJSON(&request); err != nil {
728
        HandleAppError(c, contextutils.ErrInvalidInput)
729
        return
730
    }
731

732
    // Validate non-empty user list
733
2x
    if len(request.UserIDs) == 0 {
734
        HandleAppError(c, contextutils.ErrInvalidInput)
735
        return
736
    }
737

738
    // Check if the question exists first
739
2x
    _, err = h.questionService.GetQuestionByID(c.Request.Context(), questionID)
740
2x
    if err != nil {
741
1x
        h.logger.Error(c.Request.Context(), "Failed to get question", err, map[string]interface{}{"question_id": questionID})
742
1x

743
1x
        // Check if the error is due to question not found
744
1x
        if errors.Is(err, sql.ErrNoRows) {
745
1x
            HandleAppError(c, contextutils.ErrQuestionNotFound)
746
1x
            return
747
1x
        }
748

749
        HandleAppError(c, contextutils.WrapError(err, "failed to get question"))
750
        return
751
    }
752

753
1x
    err = h.questionService.AssignUsersToQuestion(c.Request.Context(), questionID, request.UserIDs)
754
1x
    if err != nil {
755
        h.logger.Error(c.Request.Context(), "Failed to assign users to question", err, map[string]interface{}{
756
            "question_id": questionID,
757
            "user_ids":    request.UserIDs,
758
        })
759
        HandleAppError(c, contextutils.WrapError(err, "failed to assign users to question"))
760
        return
761
    }
762

763
1x
    c.JSON(http.StatusOK, gin.H{"message": "Users assigned to question successfully"})
764
}
765

766
// UnassignUsersFromQuestion removes multiple users from a question
767
2x
func (h *AdminHandler) UnassignUsersFromQuestion(c *gin.Context) {
768
2x
    questionIDStr := c.Param("id")
769
2x
    questionID, err := strconv.Atoi(questionIDStr)
770
2x
    if err != nil {
771
        HandleAppError(c, contextutils.ErrInvalidFormat)
772
        return
773
    }
774

775
2x
    var request struct {
776
2x
        UserIDs []int `json:"user_ids" binding:"required"`
777
2x
    }
778
2x

779
2x
    if err := c.ShouldBindJSON(&request); err != nil {
780
        HandleAppError(c, contextutils.NewAppErrorWithCause(contextutils.ErrorCodeInvalidInput, contextutils.SeverityWarn, "Invalid request body", "", err))
781
        return
782
    }
783

784
    // Validate non-empty user list
785
2x
    if len(request.UserIDs) == 0 {
786
        HandleAppError(c, contextutils.ErrInvalidInput)
787
        return
788
    }
789

790
    // Check if the question exists first
791
2x
    _, err = h.questionService.GetQuestionByID(c.Request.Context(), questionID)
792
2x
    if err != nil {
793
1x
        h.logger.Error(c.Request.Context(), "Failed to get question", err, map[string]interface{}{"question_id": questionID})
794
1x

795
1x
        // Check if the error is due to question not found
796
1x
        if errors.Is(err, sql.ErrNoRows) {
797
1x
            HandleAppError(c, contextutils.ErrQuestionNotFound)
798
1x
            return
799
1x
        }
800

801
        HandleAppError(c, contextutils.WrapError(err, "failed to get question"))
802
        return
803
    }
804

805
1x
    err = h.questionService.UnassignUsersFromQuestion(c.Request.Context(), questionID, request.UserIDs)
806
1x
    if err != nil {
807
        h.logger.Error(c.Request.Context(), "Failed to unassign users from question", err, map[string]interface{}{
808
            "question_id": questionID,
809
            "user_ids":    request.UserIDs,
810
        })
811
        HandleAppError(c, contextutils.WrapError(err, "failed to unassign users from question"))
812
        return
813
    }
814

815
1x
    c.JSON(http.StatusOK, gin.H{"message": "Users unassigned from question successfully"})
816
}
817

818
// DeleteQuestion deletes a question by ID
819
1x
func (h *AdminHandler) DeleteQuestion(c *gin.Context) {
820
1x
    questionIDStr := c.Param("id")
821
1x
    questionID, err := strconv.Atoi(questionIDStr)
822
1x
    if err != nil {
823
        HandleAppError(c, contextutils.ErrInvalidFormat)
824
        return
825
    }
826

827
1x
    err = h.questionService.DeleteQuestion(c.Request.Context(), questionID)
828
1x
    if err != nil {
829
        h.logger.Error(c.Request.Context(), "Failed to delete question", err, map[string]interface{}{"question_id": questionID})
830

831
        // Check if the error is due to question not found
832
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
833
            HandleAppError(c, contextutils.ErrQuestionNotFound)
834
            return
835
        }
836

837
        HandleAppError(c, contextutils.WrapError(err, "failed to delete question"))
838
        return
839
    }
840

841
1x
    c.JSON(http.StatusOK, gin.H{"message": "Question deleted successfully"})
842
}
843

844
// GetQuestionsPaginated returns paginated questions with response statistics
845
1x
func (h *AdminHandler) GetQuestionsPaginated(c *gin.Context) {
846
1x
    userIDStr := c.Query("user_id")
847
1x
    if userIDStr == "" {
848
        HandleAppError(c, contextutils.ErrMissingRequired)
849
        return
850
    }
851

852
1x
    userID, err := strconv.Atoi(userIDStr)
853
1x
    if err != nil {
854
        HandleAppError(c, contextutils.ErrInvalidFormat)
855
        return
856
    }
857

858
    // Parse pagination and filters
859
1x
    page, pageSize := ParsePagination(c, 1, 10, 100)
860
1x
    filters := ParseFilters(c, "search", "type", "status")
861
1x
    search := filters["search"]
862
1x
    typeFilter := filters["type"]
863
1x
    statusFilter := filters["status"]
864
1x

865
1x
    // Get questions with filters
866
1x
    questions, total, err := h.questionService.GetQuestionsPaginated(
867
1x
        c.Request.Context(),
868
1x
        userID,
869
1x
        page,
870
1x
        pageSize,
871
1x
        search,
872
1x
        typeFilter,
873
1x
        statusFilter,
874
1x
    )
875
1x
    if err != nil {
876
        h.logger.Error(c.Request.Context(), "Failed to get paginated questions", err, map[string]interface{}{
877
            "user_id": userID,
878
            "page":    page,
879
            "size":    pageSize,
880
        })
881
        HandleAppError(c, contextutils.WrapError(err, "failed to get questions"))
882
        return
883
    }
884

885
1x
    c.JSON(http.StatusOK, gin.H{
886
1x
        "questions": func() []map[string]interface{} {
887
1x
            out := make([]map[string]interface{}, 0, len(questions))
888
1x
            for _, q := range questions {
889
                out = append(out, convertQuestionWithStatsToAPIMap(q))
890
            }
891
1x
            return out
892
        }(),
893
        "pagination": gin.H{
894
            "page":        page,
895
            "page_size":   pageSize,
896
            "total":       total,
897
            "total_pages": int(math.Ceil(float64(total) / float64(pageSize))),
898
        },
899
    })
900
}
901

902
// GetAllQuestions returns all questions with pagination and filtering
903
func (h *AdminHandler) GetAllQuestions(c *gin.Context) {
904
    // Parse pagination and filters
905
    page, pageSize := ParsePagination(c, 1, 20, 100)
906
    f := ParseFilters(c, "search", "type", "status", "language", "level")
907
    search := f["search"]
908
    typeFilter := f["type"]
909
    statusFilter := f["status"]
910
    languageFilter := f["language"]
911
    levelFilter := f["level"]
912
    userIDStr := c.Query("user_id")
913

914
    // Parse user_id if provided
915
    var userID *int
916
    if userIDStr != "" {
917
        uid, err := strconv.Atoi(userIDStr)
918
        if err != nil {
919
            HandleAppError(c, contextutils.ErrInvalidFormat)
920
            return
921
        }
922
        userID = &uid
923
    }
924

925
    // Get questions with filters
926
    questions, total, err := h.questionService.GetAllQuestionsPaginated(
927
        c.Request.Context(),
928
        page,
929
        pageSize,
930
        search,
931
        typeFilter,
932
        statusFilter,
933
        languageFilter,
934
        levelFilter,
935
        userID,
936
    )
937
    if err != nil {
938
        h.logger.Error(c.Request.Context(), "Failed to get all questions", err, map[string]interface{}{
939
            "page":   page,
940
            "size":   pageSize,
941
            "search": search,
942
        })
943
        HandleAppError(c, contextutils.WrapError(err, "failed to get questions"))
944
        return
945
    }
946

947
    // Get stats
948
    stats, err := h.questionService.GetQuestionStats(c.Request.Context())
949
    if err != nil {
950
        h.logger.Warn(c.Request.Context(), "Failed to get question stats", map[string]interface{}{"error": err.Error()})
951
        stats = map[string]interface{}{}
952
    }
953

954
    c.JSON(http.StatusOK, gin.H{
955
        "questions": func() []map[string]interface{} {
956
            out := make([]map[string]interface{}, 0, len(questions))
957
            for _, q := range questions {
958
                out = append(out, convertQuestionWithStatsToAPIMap(q))
959
            }
960
            return out
961
        }(),
962
        "pagination": gin.H{
963
            "page":        page,
964
            "page_size":   pageSize,
965
            "total":       total,
966
            "total_pages": int(math.Ceil(float64(total) / float64(pageSize))),
967
        },
968
        "stats": stats,
969
    })
970
}
971

972
// GetReportedQuestionsPaginated returns reported questions with pagination and filtering
973
1x
func (h *AdminHandler) GetReportedQuestionsPaginated(c *gin.Context) {
974
1x
    // Parse pagination and filters
975
1x
    page, pageSize := ParsePagination(c, 1, 20, 100)
976
1x
    f := ParseFilters(c, "search", "type", "language", "level")
977
1x
    search := f["search"]
978
1x
    typeFilter := f["type"]
979
1x
    languageFilter := f["language"]
980
1x
    levelFilter := f["level"]
981
1x

982
1x
    // Get reported questions with filters
983
1x
    questions, total, err := h.questionService.GetReportedQuestionsPaginated(
984
1x
        c.Request.Context(),
985
1x
        page,
986
1x
        pageSize,
987
1x
        search,
988
1x
        typeFilter,
989
1x
        languageFilter,
990
1x
        levelFilter,
991
1x
    )
992
1x
    if err != nil {
993
        h.logger.Error(c.Request.Context(), "Failed to get reported questions", err, map[string]interface{}{
994
            "page":   page,
995
            "size":   pageSize,
996
            "search": search,
997
        })
998
        HandleAppError(c, contextutils.WrapError(err, "failed to get reported questions"))
999
        return
1000
    }
1001

1002
    // Get reported questions stats
1003
1x
    stats, err := h.questionService.GetReportedQuestionsStats(c.Request.Context())
1004
1x
    if err != nil {
1005
        h.logger.Warn(c.Request.Context(), "Failed to get reported questions stats", map[string]interface{}{"error": err.Error()})
1006
        stats = map[string]interface{}{}
1007
    }
1008

1009
1x
    c.JSON(http.StatusOK, gin.H{
1010
1x
        "questions": func() []map[string]interface{} {
1011
1x
            out := make([]map[string]interface{}, 0, len(questions))
1012
1x
            for _, q := range questions {
1013
1x
                out = append(out, convertQuestionWithStatsToAPIMap(q))
1014
1x
            }
1015
1x
            return out
1016
        }(),
1017
        "pagination": gin.H{
1018
            "page":        page,
1019
            "page_size":   pageSize,
1020
            "total":       total,
1021
            "total_pages": int(math.Ceil(float64(total) / float64(pageSize))),
1022
        },
1023
        "stats": stats,
1024
    })
1025
}
1026

1027
// ClearUserDataForUser removes all user activity data for a specific user but keeps the user record
1028
1x
func (h *AdminHandler) ClearUserDataForUser(c *gin.Context) {
1029
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "clear_user_data_for_user")
1030
1x
    defer observability.FinishSpan(span, nil)
1031
1x
    userIDStr := c.Param("id")
1032
1x
    userID, err := strconv.Atoi(userIDStr)
1033
1x
    if err != nil {
1034
        HandleAppError(c, contextutils.ErrInvalidFormat)
1035
        return
1036
    }
1037

1038
    // Check if user exists before attempting to clear data
1039
1x
    user, err := h.userService.GetUserByID(ctx, userID)
1040
1x
    if err != nil {
1041
        h.logger.Error(ctx, "Failed to get user for clear data operation", err, map[string]interface{}{"user_id": userID})
1042
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
1043
        return
1044
    }
1045
1x
    if user == nil {
1046
        HandleAppError(c, contextutils.ErrRecordNotFound)
1047
        return
1048
    }
1049

1050
1x
    err = h.userService.ClearUserDataForUser(ctx, userID)
1051
1x
    if err != nil {
1052
        h.logger.Error(ctx, "Failed to clear user data for user", err, map[string]interface{}{"user_id": userID})
1053
        HandleAppError(c, contextutils.WrapError(err, "failed to clear user data for user"))
1054
        return
1055
    }
1056
1x
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "User data cleared successfully (user preserved)"})
1057
}
1058

1059
// GetConfigz returns the merged config as pretty-printed JSON
1060
2x
func (h *AdminHandler) GetConfigz(c *gin.Context) {
1061
2x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_configz")
1062
2x
    defer observability.FinishSpan(span, nil)
1063
2x
    c.IndentedJSON(http.StatusOK, h.config)
1064
2x
}
1065

1066
// GetRoles returns all available roles in the system
1067
func (h *AdminHandler) GetRoles(c *gin.Context) {
1068
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_roles")
1069
    defer observability.FinishSpan(span, nil)
1070

1071
    // For now, return hardcoded roles since we don't have a role service
1072
    // In a real implementation, you'd query the database
1073
    roles := []models.Role{
1074
        {ID: 1, Name: "user", Description: "Normal site access", CreatedAt: time.Now(), UpdatedAt: time.Now()},
1075
        {ID: 2, Name: "admin", Description: "Administrative access to all features", CreatedAt: time.Now(), UpdatedAt: time.Now()},
1076
    }
1077

1078
    c.JSON(http.StatusOK, gin.H{"roles": roles})
1079
}
1080

1081
// GetUserRoles returns all roles for a specific user
1082
func (h *AdminHandler) GetUserRoles(c *gin.Context) {
1083
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_user_roles")
1084
    defer observability.FinishSpan(span, nil)
1085

1086
    userIDStr := c.Param("id")
1087
    userID, err := strconv.Atoi(userIDStr)
1088
    if err != nil {
1089
        HandleAppError(c, contextutils.ErrInvalidFormat)
1090
        return
1091
    }
1092

1093
    // Check if user exists before getting roles
1094
    user, err := h.userService.GetUserByID(ctx, userID)
1095
    if err != nil {
1096
        h.logger.Error(ctx, "Failed to get user for roles operation", err, map[string]interface{}{"user_id": userID})
1097
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
1098
        return
1099
    }
1100
    if user == nil {
1101
        HandleAppError(c, contextutils.ErrRecordNotFound)
1102
        return
1103
    }
1104

1105
    roles, err := h.userService.GetUserRoles(ctx, userID)
1106
    if err != nil {
1107
        h.logger.Error(ctx, "Failed to get user roles", err, map[string]interface{}{"user_id": userID})
1108
        HandleAppError(c, contextutils.WrapError(err, "failed to get user roles"))
1109
        return
1110
    }
1111

1112
    c.JSON(http.StatusOK, gin.H{"roles": roles})
1113
}
1114

1115
// AssignRole assigns a role to a user
1116
func (h *AdminHandler) AssignRole(c *gin.Context) {
1117
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "assign_role")
1118
    defer observability.FinishSpan(span, nil)
1119

1120
    userIDStr := c.Param("id")
1121
    userID, err := strconv.Atoi(userIDStr)
1122
    if err != nil {
1123
        HandleAppError(c, contextutils.ErrInvalidFormat)
1124
        return
1125
    }
1126

1127
    // Check if user exists before assigning role
1128
    user, err := h.userService.GetUserByID(ctx, userID)
1129
    if err != nil {
1130
        h.logger.Error(ctx, "Failed to get user for role assignment", err, map[string]interface{}{"user_id": userID})
1131
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
1132
        return
1133
    }
1134
    if user == nil {
1135
        HandleAppError(c, contextutils.ErrRecordNotFound)
1136
        return
1137
    }
1138

1139
    var req struct {
1140
        RoleID int `json:"role_id" binding:"required"`
1141
    }
1142
    if err := c.ShouldBindJSON(&req); err != nil {
1143
        HandleAppError(c, contextutils.NewAppErrorWithCause(contextutils.ErrorCodeInvalidInput, contextutils.SeverityWarn, "Invalid request body", "", err))
1144
        return
1145
    }
1146

1147
    // Ensure the requester is allowed (self or admin). Route is admin-only, but keep explicit check.
1148
    currentUserID, err := GetCurrentUserID(c)
1149
    if err == nil {
1150
        if err := RequireSelfOrAdmin(ctx, h.userService, currentUserID, userID); err != nil {
1151
            if errors.Is(err, ErrForbidden) {
1152
                HandleAppError(c, contextutils.ErrForbidden)
1153
                return
1154
            }
1155
            h.logger.Error(ctx, "Failed to check authorization", err, map[string]interface{}{"user_id": currentUserID})
1156
            HandleAppError(c, contextutils.WrapError(err, "failed to check authorization"))
1157
            return
1158
        }
1159
    }
1160

1161
    err = h.userService.AssignRole(ctx, userID, req.RoleID)
1162
    if err != nil {
1163
        h.logger.Error(ctx, "Failed to assign role to user", err, map[string]interface{}{"user_id": userID, "role_id": req.RoleID})
1164
        HandleAppError(c, contextutils.WrapError(err, "failed to assign role"))
1165
        return
1166
    }
1167

1168
    c.JSON(http.StatusOK, gin.H{"message": "Role assigned successfully"})
1169
}
1170

1171
// RemoveRole removes a role from a user
1172
func (h *AdminHandler) RemoveRole(c *gin.Context) {
1173
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "remove_role")
1174
    defer observability.FinishSpan(span, nil)
1175

1176
    userIDStr := c.Param("id")
1177
    userID, err := strconv.Atoi(userIDStr)
1178
    if err != nil {
1179
        HandleAppError(c, contextutils.ErrInvalidFormat)
1180
        return
1181
    }
1182

1183
    // Check if user exists before removing role
1184
    user, err := h.userService.GetUserByID(ctx, userID)
1185
    if err != nil {
1186
        h.logger.Error(ctx, "Failed to get user for role removal", err, map[string]interface{}{"user_id": userID})
1187
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
1188
        return
1189
    }
1190
    if user == nil {
1191
        HandleAppError(c, contextutils.ErrRecordNotFound)
1192
        return
1193
    }
1194

1195
    roleIDStr := c.Param("roleId")
1196
    roleID, err := strconv.Atoi(roleIDStr)
1197
    if err != nil {
1198
        HandleAppError(c, contextutils.ErrInvalidFormat)
1199
        return
1200
    }
1201

1202
    // Ensure the requester is allowed (self or admin). Route is admin-only, but keep explicit check.
1203
    currentUserID, err := GetCurrentUserID(c)
1204
    if err == nil {
1205
        if err := RequireSelfOrAdmin(ctx, h.userService, currentUserID, userID); err != nil {
1206
            if errors.Is(err, ErrForbidden) {
1207
                HandleAppError(c, contextutils.ErrForbidden)
1208
                return
1209
            }
1210
            h.logger.Error(ctx, "Failed to check authorization", err, map[string]interface{}{"user_id": currentUserID})
1211
            HandleAppError(c, contextutils.WrapError(err, "failed to check authorization"))
1212
            return
1213
        }
1214
    }
1215

1216
    err = h.userService.RemoveRole(ctx, userID, roleID)
1217
    if err != nil {
1218
        h.logger.Error(ctx, "Failed to remove role", err, map[string]interface{}{"user_id": userID, "role_id": roleID})
1219

1220
        // Check if it's a "user does not have role" error
1221
        if strings.Contains(err.Error(), "does not have role") {
1222
            HandleAppError(c, contextutils.ErrRecordNotFound)
1223
            return
1224
        }
1225

1226
        // Check if it's a "user not found" or "role not found" error
1227
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
1228
            HandleAppError(c, contextutils.ErrRecordNotFound)
1229
            return
1230
        }
1231

1232
        HandleAppError(c, contextutils.WrapError(err, "failed to remove role"))
1233
        return
1234
    }
1235

1236
    c.JSON(http.StatusOK, gin.H{"message": "Role removed successfully"})
1237
}
1238

1239
// calculateUserAggregateStats calculates aggregate statistics for all users
1240
func calculateUserAggregateStats(ctx context.Context, users []models.User, learningService services.LearningServiceInterface, logger *observability.Logger) map[string]interface{} {
1241
    stats := map[string]interface{}{
1242
        "total_users":              len(users),
1243
        "by_language":              make(map[string]int),
1244
        "by_level":                 make(map[string]int),
1245
        "by_ai_provider":           make(map[string]int),
1246
        "by_ai_model":              make(map[string]int),
1247
        "ai_enabled":               0,
1248
        "ai_disabled":              0,
1249
        "active_users":             0,
1250
        "inactive_users":           0,
1251
        "total_questions_answered": 0,
1252
        "total_correct_answers":    0,
1253
        "average_accuracy":         0.0,
1254
    }
1255

1256
    activeThreshold := time.Now().AddDate(0, 0, -7)
1257

1258
    for _, user := range users {
1259
        lang := "unknown"
1260
        if user.PreferredLanguage.Valid {
1261
            lang = user.PreferredLanguage.String
1262
        }
1263
        stats["by_language"].(map[string]int)[lang]++
1264

1265
        level := "unknown"
1266
        if user.CurrentLevel.Valid {
1267
            level = user.CurrentLevel.String
1268
        }
1269
        stats["by_level"].(map[string]int)[level]++
1270

1271
        provider := "none"
1272
        if user.AIProvider.Valid {
1273
            provider = user.AIProvider.String
1274
        }
1275
        stats["by_ai_provider"].(map[string]int)[provider]++
1276

1277
        model := "none"
1278
        if user.AIModel.Valid {
1279
            model = user.AIModel.String
1280
        }
1281
        stats["by_ai_model"].(map[string]int)[model]++
1282

1283
        if user.AIEnabled.Valid && user.AIEnabled.Bool {
1284
            aiEnabled := stats["ai_enabled"].(int)
1285
            stats["ai_enabled"] = aiEnabled + 1
1286
        } else {
1287
            aiDisabled := stats["ai_disabled"].(int)
1288
            stats["ai_disabled"] = aiDisabled + 1
1289
        }
1290

1291
        if user.LastActive.Valid {
1292
            lastActive := user.LastActive.Time
1293
            if lastActive.After(activeThreshold) {
1294
                activeUsers := stats["active_users"].(int)
1295
                stats["active_users"] = activeUsers + 1
1296
            } else {
1297
                inactiveUsers := stats["inactive_users"].(int)
1298
                stats["inactive_users"] = inactiveUsers + 1
1299
            }
1300
        } else {
1301
            inactiveUsers := stats["inactive_users"].(int)
1302
            stats["inactive_users"] = inactiveUsers + 1
1303
        }
1304

1305
        progress, err := learningService.GetUserProgress(ctx, user.ID)
1306
        if err != nil {
1307
            logger.Warn(ctx, "Failed to get progress for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
1308
            continue
1309
        }
1310

1311
        if progress != nil {
1312
            totalAnswered := stats["total_questions_answered"].(int)
1313
            stats["total_questions_answered"] = totalAnswered + progress.TotalQuestions
1314

1315
            totalCorrect := stats["total_correct_answers"].(int)
1316
            stats["total_correct_answers"] = totalCorrect + progress.CorrectAnswers
1317
        }
1318
    }
1319

1320
    totalAnswered := stats["total_questions_answered"].(int)
1321
    if totalAnswered > 0 {
1322
        stats["average_accuracy"] = float64(stats["total_correct_answers"].(int)) / float64(totalAnswered) * 100.0
1323
    }
1324

1325
    return stats
1326
}
1327


			
quizapp internal handlers worker_admin_handler.go
77.6%
Statements
59/76
1
package handlers
2

3
import (
4
    "encoding/json"
5
    "fmt"
6
    "strconv"
7
    "strings"
8
)
9

10
// MergeAISuggestion merges AI response into the original question map.
11
// It ensures top-level metadata from original are preserved and AI-provided
12
// content is merged into original["content"]. It moves top-level correct_answer
13
// and explanation into content to avoid duplicates.
14
6x
func MergeAISuggestion(original, aiResp map[string]interface{}) map[string]interface{} {
15
6x
    // copy original to avoid mutating caller's map
16
6x
    out := map[string]interface{}{}
17
6x
    b, _ := json.Marshal(original)
18
6x
    _ = json.Unmarshal(b, &out)
19
6x

20
6x
    // ensure content map exists
21
6x
    contentIface := out["content"]
22
6x
    contentMap, _ := contentIface.(map[string]interface{})
23
6x
    if contentMap == nil {
24
        contentMap = map[string]interface{}{}
25
        out["content"] = contentMap
26
    }
27

28
    // merge ai content
29
6x
    if aiContentRaw, ok := aiResp["content"]; ok {
30
6x
        if aiContentMap, ok2 := aiContentRaw.(map[string]interface{}); ok2 {
31
6x
            for k, v := range aiContentMap {
32
22x
                contentMap[k] = v
33
22x
            }
34
        }
35
    }
36

37
    // move top-level fields into content
38
6x
    if ca, ok := aiResp["correct_answer"]; ok {
39
4x
        contentMap["correct_answer"] = ca
40
4x
        delete(aiResp, "correct_answer")
41
4x
    }
42
6x
    if ex, ok := aiResp["explanation"]; ok {
43
4x
        contentMap["explanation"] = ex
44
4x
        delete(aiResp, "explanation")
45
4x
    }
46

47
6x
    if cr, ok := aiResp["change_reason"]; ok {
48
6x
        out["change_reason"] = cr
49
6x
    }
50

51
6x
    NormalizeContent(contentMap)
52
6x

53
6x
    return out
54
}
55

56
// NormalizeContent attempts to sanitize content fields: options->[]string,
57
// correct_answer->int, trims duplicates and clamps indices.
58
6x
func NormalizeContent(contentMap map[string]interface{}) {
59
6x
    // normalize options
60
6x
    if optsRaw, ok := contentMap["options"]; ok {
61
6x
        switch opts := optsRaw.(type) {
62
4x
        case []interface{}:
63
4x
            seen := map[string]bool{}
64
4x
            var out []string
65
4x
            for _, it := range opts {
66
16x
                s, ok := it.(string)
67
16x
                if !ok {
68
                    continue
69
                }
70
16x
                s = strings.TrimSpace(s)
71
16x
                if s == "" {
72
                    continue
73
                }
74
16x
                if !seen[s] {
75
16x
                    out = append(out, s)
76
16x
                    seen[s] = true
77
16x
                }
78
            }
79
4x
            contentMap["options"] = out
80
        case []string:
81
            // ok
82
1x
        case string:
83
1x
            var parsed []string
84
1x
            if err := json.Unmarshal([]byte(opts), &parsed); err == nil {
85
                contentMap["options"] = parsed
86
            } else {
87
1x
                parts := strings.FieldsFunc(opts, func(r rune) bool { return r == '\n' || r == ',' })
88
                var out []string
89
1x
                seen := map[string]bool{}
90
1x
                for _, p := range parts {
91
4x
                    p = strings.TrimSpace(p)
92
4x
                    if p == "" {
93
                        continue
94
                    }
95
4x
                    if !seen[p] {
96
4x
                        out = append(out, p)
97
4x
                        seen[p] = true
98
4x
                    }
99
                }
100
1x
                contentMap["options"] = out
101
            }
102
        default:
103
            delete(contentMap, "options")
104
        }
105
    }
106

107
    // ensure options slice is []string
108
6x
    if optsI, ok := contentMap["options"].([]interface{}); ok {
109
        var out []string
110
        for _, it := range optsI {
111
            if s, ok := it.(string); ok {
112
                out = append(out, s)
113
            }
114
        }
115
        contentMap["options"] = out
116
    }
117

118
    // normalize correct_answer
119
6x
    if ca, ok := contentMap["correct_answer"]; ok {
120
6x
        switch v := ca.(type) {
121
4x
        case float64:
122
4x
            contentMap["correct_answer"] = int(v)
123
        case int:
124
            // ok
125
1x
        case string:
126
1x
            if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
127
1x
                contentMap["correct_answer"] = n
128
1x
            } else {
129
                delete(contentMap, "correct_answer")
130
            }
131
        default:
132
            delete(contentMap, "correct_answer")
133
        }
134
    }
135

136
    // clamp correct_answer to options length
137
6x
    if ca, ok := contentMap["correct_answer"].(int); ok {
138
6x
        if opts, ok := contentMap["options"].([]string); ok {
139
6x
            if len(opts) == 0 {
140
                contentMap["correct_answer"] = 0
141
            } else if ca < 0 || ca >= len(opts) {
142
                contentMap["correct_answer"] = 0
143
            }
144
        }
145
    }
146

147
    // ensure simple string fields
148
6x
    for _, k := range []string{"explanation", "question", "passage", "sentence"} {
149
24x
        if v, ok := contentMap[k]; ok {
150
12x
            switch t := v.(type) {
151
12x
            case string:
152
                // ok
153
            default:
154
                contentMap[k] = fmt.Sprint(t)
155
            }
156
        }
157
    }
158
}
159


			
quizapp internal handlers worker_admin_handler.go
73.2%
Statements
213/291
1
package handlers
2

3
import (
4
    "crypto/rand"
5
    "errors"
6
    "net/http"
7
    "regexp"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/api"
12
    "quizapp/internal/config"
13
    "quizapp/internal/middleware"
14
    "quizapp/internal/observability"
15
    "quizapp/internal/services"
16
    contextutils "quizapp/internal/utils"
17

18
    "github.com/gin-contrib/sessions"
19
    "github.com/gin-gonic/gin"
20
    openapi_types "github.com/oapi-codegen/runtime/types"
21
    "go.opentelemetry.io/otel/attribute"
22
)
23

24
// AuthHandler handles authentication related HTTP requests
25
type AuthHandler struct {
26
    userService  services.UserServiceInterface
27
    oauthService *services.OAuthService
28
    config       *config.Config
29
    logger       *observability.Logger
30
}
31

32
// NewAuthHandler creates a new AuthHandler instance
33
53x
func NewAuthHandler(userService services.UserServiceInterface, oauthService *services.OAuthService, cfg *config.Config, logger *observability.Logger) *AuthHandler {
34
53x
    return &AuthHandler{
35
53x
        userService:  userService,
36
53x
        oauthService: oauthService,
37
53x
        config:       cfg,
38
53x
        logger:       logger,
39
53x
    }
40
53x
}
41

42
// Login handles user login requests
43
139x
func (h *AuthHandler) Login(c *gin.Context) {
44
139x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "login")
45
139x
    defer observability.FinishSpan(span, nil)
46
139x

47
139x
    var req api.LoginRequest
48
139x
    if err := c.ShouldBindJSON(&req); err != nil {
49
3x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
50
3x
            contextutils.ErrorCodeInvalidInput,
51
3x
            contextutils.SeverityWarn,
52
3x
            "Invalid request body",
53
3x
            "",
54
3x
            err,
55
3x
        ))
56
3x
        return
57
3x
    }
58

59
    // Set span attributes for observability
60
136x
    span.SetAttributes(
61
136x
        attribute.String("auth.username", req.Username),
62
136x
        attribute.Bool("auth.password_provided", req.Password != ""),
63
136x
    )
64
136x

65
136x
    // Authenticate user against database
66
136x
    user, err := h.userService.AuthenticateUser(c.Request.Context(), req.Username, req.Password)
67
136x
    if err != nil {
68
10x
        h.logger.Error(c.Request.Context(), "Authentication failed for user", err, map[string]interface{}{"username": req.Username})
69
10x
        HandleAppError(c, contextutils.ErrInvalidCredentials)
70
10x
        return
71
10x
    }
72

73
126x
    if user == nil {
74
        HandleAppError(c, contextutils.ErrInvalidCredentials)
75
        return
76
    }
77

78
    // Update span attributes with user info
79
126x
    span.SetAttributes(
80
126x
        attribute.Int("user.id", user.ID),
81
126x
        attribute.String("user.username", user.Username),
82
126x
        attribute.Bool("user.email_provided", user.Email.Valid),
83
126x
        attribute.String("user.language", user.PreferredLanguage.String),
84
126x
        attribute.String("user.level", user.CurrentLevel.String),
85
126x
    )
86
126x

87
126x
    // Update last active
88
126x
    if err := h.userService.UpdateLastActive(c.Request.Context(), user.ID); err != nil {
89
        // Log error but don't fail login
90
        // In production, you'd want proper logging here
91
        h.logger.Warn(c.Request.Context(), "Failed to update last active for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
92
    }
93

94
    // Create session
95
126x
    session := sessions.Default(c)
96
126x
    session.Set(middleware.UserIDKey, user.ID)
97
126x
    session.Set(middleware.UsernameKey, user.Username)
98
126x

99
126x
    if err := session.Save(); err != nil {
100
        h.logger.Error(c.Request.Context(), "Failed to save session", err, map[string]interface{}{"error": err.Error()})
101
        HandleAppError(c, contextutils.WrapError(err, "failed to create session"))
102
        return
103
    }
104

105
    // Convert models.User to api.User with proper API key checking
106
126x
    apiUser := convertUserToAPIWithService(c.Request.Context(), user, h.userService)
107
126x

108
126x
    // Return user info (without API key)
109
126x
    c.JSON(http.StatusOK, api.LoginResponse{
110
126x
        Success: boolPtr(true),
111
126x
        Message: stringPtr("Login successful"),
112
126x
        User:    &apiUser,
113
126x
    })
114
}
115

116
// Logout handles user logout requests
117
4x
func (h *AuthHandler) Logout(c *gin.Context) {
118
4x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "logout")
119
4x
    defer observability.FinishSpan(span, nil)
120
4x

121
4x
    // Get user info before clearing session for tracing
122
4x
    session := sessions.Default(c)
123
4x
    userID := session.Get(middleware.UserIDKey)
124
4x
    username := session.Get(middleware.UsernameKey)
125
4x

126
4x
    // Set span attributes
127
4x
    if userID != nil {
128
1x
        span.SetAttributes(attribute.Int("user.id", userID.(int)))
129
1x
    }
130
4x
    if username != nil {
131
        span.SetAttributes(attribute.String("user.username", username.(string)))
132
    }
133

134
4x
    session.Clear()
135
4x

136
4x
    if err := session.Save(); err != nil {
137
        HandleAppError(c, contextutils.WrapError(err, "failed to clear session"))
138
        return
139
    }
140

141
4x
    c.JSON(http.StatusOK, api.SuccessResponse{
142
4x
        Success: true,
143
4x
        Message: stringPtr("Logout successful"),
144
4x
    })
145
}
146

147
// Status returns the current authentication status
148
10x
func (h *AuthHandler) Status(c *gin.Context) {
149
10x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "status")
150
10x
    defer observability.FinishSpan(span, nil)
151
10x

152
10x
    session := sessions.Default(c)
153
10x
    userID := session.Get(middleware.UserIDKey)
154
10x

155
10x
    if userID == nil {
156
6x
        span.SetAttributes(attribute.Bool("auth.authenticated", false))
157
6x
        c.JSON(http.StatusOK, gin.H{
158
6x
            "authenticated": false,
159
6x
            "user":          nil,
160
6x
        })
161
6x
        return
162
6x
    }
163

164
4x
    span.SetAttributes(
165
4x
        attribute.Bool("auth.authenticated", true),
166
4x
        attribute.Int("user.id", userID.(int)),
167
4x
    )
168
4x

169
4x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID.(int))
170
4x
    if err != nil {
171
        h.logger.Error(c.Request.Context(), "Error getting user by ID", err, map[string]interface{}{"user_id": userID.(int)})
172
        HandleAppError(c, contextutils.ErrInternalError)
173
        return
174
    }
175

176
4x
    if user == nil {
177
        // User not found, clear session
178
        session.Clear()
179
        if err := session.Save(); err != nil {
180
            h.logger.Error(c.Request.Context(), "Error saving session", err, map[string]interface{}{"error": err.Error()})
181
        }
182
        span.SetAttributes(attribute.Bool("auth.user_found", false))
183
        c.JSON(http.StatusOK, gin.H{
184
            "authenticated": false,
185
            "user":          nil,
186
        })
187
        return
188
    }
189

190
    // Update span attributes with user info
191
4x
    span.SetAttributes(
192
4x
        attribute.Bool("auth.user_found", true),
193
4x
        attribute.String("user.username", user.Username),
194
4x
        attribute.Bool("user.email_provided", user.Email.Valid),
195
4x
        attribute.String("user.language", user.PreferredLanguage.String),
196
4x
        attribute.String("user.level", user.CurrentLevel.String),
197
4x
        attribute.Bool("user.ai_enabled", user.AIEnabled.Bool),
198
4x
        attribute.String("user.ai_provider", user.AIProvider.String),
199
4x
        attribute.String("user.ai_model", user.AIModel.String),
200
4x
    )
201
4x

202
4x
    // Update last active timestamp
203
4x
    if err := h.userService.UpdateLastActive(c.Request.Context(), user.ID); err != nil {
204
        h.logger.Error(c.Request.Context(), "Error updating last active", err, map[string]interface{}{"user_id": user.ID})
205
        // Don't fail the request for this error
206
    }
207

208
    // Convert models.User to api.User with proper API key checking
209
4x
    apiUser := convertUserToAPIWithService(c.Request.Context(), user, h.userService)
210
4x

211
4x
    c.JSON(http.StatusOK, gin.H{
212
4x
        "authenticated": true,
213
4x
        "user":          &apiUser,
214
4x
    })
215
}
216

217
// Check is a lightweight auth-check endpoint intended for reverse proxy auth_request.
218
// It requires authentication via middleware and returns 204 when authenticated.
219
// Unauthenticated requests are rejected by the RequireAuth middleware with 401.
220
func (h *AuthHandler) Check(c *gin.Context) {
221
    // If we reached here, authentication succeeded in middleware
222
    c.Status(http.StatusNoContent)
223
}
224

225
// Signup handles user registration requests
226
26x
func (h *AuthHandler) Signup(c *gin.Context) {
227
26x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "signup")
228
26x
    defer observability.FinishSpan(span, nil)
229
26x

230
26x
    // Check if signups are disabled
231
26x
    if h.config != nil && h.config.IsSignupDisabled() {
232
1x
        span.SetAttributes(attribute.Bool("auth.signups_disabled", true))
233
1x
        HandleAppError(c, contextutils.ErrForbidden)
234
1x
        return
235
1x
    }
236

237
24x
    span.SetAttributes(attribute.Bool("auth.signups_disabled", false))
238
24x

239
24x
    var req api.UserCreateRequest
240
24x
    if err := c.ShouldBindJSON(&req); err != nil {
241
1x
        if errors.Is(err, openapi_types.ErrValidationEmail) {
242
1x
            HandleAppError(c, contextutils.ErrInvalidInput)
243
1x
            return
244
1x
        }
245
        HandleAppError(c, contextutils.NewAppErrorWithCause(
246
            contextutils.ErrorCodeInvalidInput,
247
            contextutils.SeverityWarn,
248
            "Invalid request body",
249
            "",
250
            err,
251
        ))
252
        return
253
    }
254

255
    // Set span attributes for request data
256
22x
    span.SetAttributes(
257
22x
        attribute.String("signup.username", req.Username),
258
22x
        attribute.Bool("signup.password_provided", req.Password != ""),
259
22x
        attribute.Bool("signup.email_provided", req.Email != nil && *req.Email != ""),
260
22x
        attribute.Bool("signup.language_provided", req.PreferredLanguage != nil && *req.PreferredLanguage != ""),
261
22x
        attribute.Bool("signup.level_provided", req.CurrentLevel != nil && *req.CurrentLevel != ""),
262
22x
        attribute.Bool("signup.timezone_provided", req.Timezone != nil && *req.Timezone != ""),
263
22x
    )
264
22x

265
22x
    // Validate required fields
266
22x
    if req.Username == "" {
267
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
268
1x
        return
269
1x
    }
270

271
20x
    if req.Password == "" {
272
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
273
1x
        return
274
1x
    }
275

276
18x
    if req.Email == nil || *req.Email == "" {
277
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
278
1x
        return
279
1x
    }
280

281
    // Validate username format (3-50 characters, alphanumeric + underscore)
282
16x
    if len(req.Username) < 3 || len(req.Username) > 50 {
283
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
284
1x
        return
285
1x
    }
286

287
14x
    usernameRegex := regexp.MustCompile(`^[a-zA-Z0-9_]+$`)
288
14x
    if !usernameRegex.MatchString(req.Username) {
289
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
290
1x
        return
291
1x
    }
292

293
    // Validate password (minimum 8 characters)
294
12x
    if len(req.Password) < 8 {
295
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
296
1x
        return
297
1x
    }
298

299
    // Validate email format (convert to string)
300
10x
    if !contextutils.IsValidEmail(string(*req.Email)) {
301
        HandleAppError(c, contextutils.ErrInvalidFormat)
302
        return
303
    }
304

305
    // Normalize email to lowercase
306
10x
    email := strings.ToLower(string(*req.Email))
307
10x

308
10x
    h.logger.Info(c.Request.Context(), "Attempting signup for user", map[string]interface{}{"username": req.Username, "email": email})
309
10x

310
10x
    // Check if username already exists
311
10x
    existingUser, err := h.userService.GetUserByUsername(c.Request.Context(), req.Username)
312
10x
    if err != nil {
313
        h.logger.Error(c.Request.Context(), "Error checking username uniqueness", err, map[string]interface{}{"username": req.Username})
314
        HandleAppError(c, contextutils.ErrInternalError)
315
        return
316
    }
317

318
10x
    if existingUser != nil {
319
3x
        span.SetAttributes(attribute.Bool("signup.username_exists", true))
320
3x
        HandleAppError(c, contextutils.ErrRecordExists)
321
3x
        return
322
3x
    }
323

324
    // Check if email already exists
325
7x
    existingUserByEmail, err := h.userService.GetUserByEmail(c.Request.Context(), email)
326
7x
    if err != nil {
327
        h.logger.Error(c.Request.Context(), "Error checking email uniqueness", err, map[string]interface{}{"email": email})
328
        HandleAppError(c, contextutils.ErrInternalError)
329
        return
330
    }
331

332
7x
    if existingUserByEmail != nil {
333
1x
        span.SetAttributes(attribute.Bool("signup.email_exists", true))
334
1x
        HandleAppError(c, contextutils.ErrRecordExists)
335
1x
        return
336
1x
    }
337

338
    // Set default values for optional fields
339
5x
    language := "italian" // Default to first language in the list
340
5x
    if h.config != nil {
341
5x
        // Get available languages from config
342
5x
        languages := h.config.GetLanguages()
343
5x
        if len(languages) > 0 {
344
5x
            language = languages[0]
345
5x
        }
346
    }
347
5x
    if req.PreferredLanguage != nil && *req.PreferredLanguage != "" {
348
2x
        language = *req.PreferredLanguage
349
2x
    }
350

351
    // Choose canonical default level for the selected language (first level in config)
352
5x
    level := ""
353
5x
    levels := []string{}
354
5x
    if h.config != nil {
355
5x
        levels = h.config.GetLevelsForLanguage(language)
356
5x
        if len(levels) > 0 {
357
5x
            level = levels[0]
358
5x
        }
359
    }
360

361
    // If client provided a level, require it to be a canonical code for the language.
362
5x
    if req.CurrentLevel != nil && *req.CurrentLevel != "" {
363
2x
        provided := *req.CurrentLevel
364
2x
        matched := false
365
2x
        for _, l := range levels {
366
4x
            if strings.EqualFold(l, provided) {
367
2x
                level = l
368
2x
                matched = true
369
2x
                break
370
            }
371
        }
372
2x
        if !matched {
373
            HandleAppError(c, contextutils.ErrInvalidFormat)
374
            return
375
        }
376
    }
377

378
5x
    timezone := "UTC" // Default timezone
379
5x
    if req.Timezone != nil && *req.Timezone != "" {
380
2x
        timezone = *req.Timezone
381
2x
    }
382

383
    // Update span attributes with final values
384
5x
    span.SetAttributes(
385
5x
        attribute.String("signup.language", language),
386
5x
        attribute.String("signup.level", level),
387
5x
        attribute.String("signup.timezone", timezone),
388
5x
    )
389
5x

390
5x
    // Create user with email and timezone (no AI settings)
391
5x
    user, err := h.userService.CreateUserWithEmailAndTimezone(c.Request.Context(), req.Username, email, timezone, language, level)
392
5x
    if err != nil {
393
        h.logger.Error(c.Request.Context(), "Error creating user", err, map[string]interface{}{"username": req.Username, "email": email})
394
        HandleAppError(c, contextutils.WrapError(err, "failed to create user account"))
395
        return
396
    }
397

398
    // Now set the password hash
399
5x
    if err := h.userService.UpdateUserPassword(c.Request.Context(), user.ID, req.Password); err != nil {
400
        h.logger.Error(c.Request.Context(), "Error setting user password", err, map[string]interface{}{"user_id": user.ID})
401
        // Try to clean up the user we just created
402
        if deleteErr := h.userService.DeleteUser(c.Request.Context(), user.ID); deleteErr != nil {
403
            h.logger.Error(c.Request.Context(), "Error cleaning up user after password set failure", err, map[string]interface{}{"user_id": user.ID, "error": deleteErr.Error()})
404
        }
405
        HandleAppError(c, contextutils.WrapError(err, "failed to create user account"))
406
        return
407
    }
408

409
    // Update span attributes with created user info
410
5x
    span.SetAttributes(
411
5x
        attribute.Int("user.id", user.ID),
412
5x
        attribute.String("user.username", user.Username),
413
5x
        attribute.String("user.email", email),
414
5x
    )
415
5x

416
5x
    h.logger.Info(c.Request.Context(), "Successfully created user", map[string]interface{}{"username": req.Username, "user_id": user.ID})
417
5x

418
5x
    // Return success response (no session created, no auto-login)
419
5x
    c.JSON(http.StatusCreated, api.SuccessResponse{
420
5x
        Success: true,
421
5x
        Message: stringPtr("Account created successfully. Please log in."),
422
5x
    })
423
}
424

425
// GoogleLogin initiates Google OAuth flow
426
19x
func (h *AuthHandler) GoogleLogin(c *gin.Context) {
427
19x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "google_login")
428
19x
    defer observability.FinishSpan(span, nil)
429
19x

430
19x
    // Generate a state parameter for security
431
19x
    state := generateRandomState()
432
19x

433
19x
    // Get the redirect URI from query parameters
434
19x
    redirectURI := c.Query("redirect_uri")
435
19x

436
19x
    // Set span attributes
437
19x
    span.SetAttributes(
438
19x
        attribute.String("oauth.provider", "google"),
439
19x
        attribute.String("oauth.state", state),
440
19x
        attribute.String("oauth.redirect_uri", redirectURI),
441
19x
    )
442
19x

443
19x
    // Store state and redirect URI in session for verification
444
19x
    session := sessions.Default(c)
445
19x
    session.Set("oauth_state", state)
446
19x
    if redirectURI != "" {
447
1x
        session.Set("oauth_redirect_uri", redirectURI)
448
1x
    }
449
19x
    if err := session.Save(); err != nil {
450
        HandleAppError(c, contextutils.WrapError(err, "failed to save session"))
451
        return
452
    }
453

454
    // Generate Google OAuth URL
455
19x
    authURL := h.oauthService.GetGoogleAuthURL(c.Request.Context(), state)
456
19x

457
19x
    c.JSON(http.StatusOK, gin.H{
458
19x
        "auth_url": authURL,
459
19x
    })
460
}
461

462
// GoogleCallback handles the OAuth callback from Google
463
19x
func (h *AuthHandler) GoogleCallback(c *gin.Context) {
464
19x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "google_callback")
465
19x
    defer observability.FinishSpan(span, nil)
466
19x

467
19x
    // Get the authorization code and state from query parameters
468
19x
    code := c.Query("code")
469
19x
    state := c.Query("state")
470
19x

471
19x
    // Set span attributes
472
19x
    span.SetAttributes(
473
19x
        attribute.String("oauth.provider", "google"),
474
19x
        attribute.Bool("oauth.code_provided", code != ""),
475
19x
        attribute.String("oauth.state", state),
476
19x
    )
477
19x

478
19x
    h.logger.Info(c.Request.Context(), "Google OAuth callback received", map[string]interface{}{"code": code, "state": state})
479
19x

480
19x
    if code == "" {
481
4x
        HandleAppError(c, contextutils.ErrMissingRequired)
482
4x
        return
483
4x
    }
484

485
    // Verify state parameter for OAuth security (CSRF protection)
486
15x
    session := sessions.Default(c)
487
15x
    storedState := session.Get("oauth_state")
488
15x

489
15x
    h.logger.Info(c.Request.Context(), "OAuth state verification", map[string]interface{}{"stored_state": storedState, "received_state": state})
490
15x

491
15x
    // Enforce strict state verification for security
492
15x
    if storedState == nil {
493
5x
        h.logger.Error(c.Request.Context(), "No OAuth state found in session - possible CSRF attack or session issue", nil, map[string]interface{}{"state": state})
494
5x
        span.SetAttributes(attribute.Bool("oauth.state_valid", false))
495
5x
        HandleAppError(c, contextutils.ErrOAuthStateMismatch)
496
5x
        return
497
5x
    }
498

499
10x
    if storedState.(string) != state {
500
4x
        h.logger.Error(c.Request.Context(), "OAuth state mismatch - possible CSRF attack", nil, map[string]interface{}{"stored_state": storedState.(string), "received_state": state})
501
4x
        span.SetAttributes(attribute.Bool("oauth.state_valid", false))
502
4x
        HandleAppError(c, contextutils.ErrOAuthStateMismatch)
503
4x
        return
504
4x
    }
505

506
3x
    span.SetAttributes(attribute.Bool("oauth.state_valid", true))
507
3x
    h.logger.Info(c.Request.Context(), "OAuth state verification successful")
508
3x

509
3x
    // Check if user is already authenticated (prevent duplicate callbacks)
510
3x
    existingUserID := session.Get(middleware.UserIDKey)
511
3x
    if existingUserID != nil {
512
        h.logger.Info(c.Request.Context(), "User already authenticated during OAuth callback", map[string]interface{}{
513
            "user_id": existingUserID.(int),
514
        })
515
        span.SetAttributes(attribute.Bool("oauth.duplicate_callback", true))
516

517
        // Get user information for the response
518
        user, err := h.userService.GetUserByID(c.Request.Context(), existingUserID.(int))
519
        if err != nil {
520
            h.logger.Error(c.Request.Context(), "Error getting user by ID", err, map[string]interface{}{"user_id": existingUserID.(int)})
521
            HandleAppError(c, contextutils.ErrInternalError)
522
            return
523
        }
524

525
        if user == nil {
526
            h.logger.Error(c.Request.Context(), "User not found", nil, map[string]interface{}{"user_id": existingUserID.(int)})
527
            HandleAppError(c, contextutils.ErrInternalError)
528
            return
529
        }
530

531
        // Convert models.User to api.User with proper API key checking
532
        apiUser := convertUserToAPIWithService(c.Request.Context(), user, h.userService)
533

534
        // Return success response for already authenticated user
535
        response := api.LoginResponse{
536
            Success: boolPtr(true),
537
            Message: stringPtr("Already authenticated"),
538
            User:    &apiUser,
539
        }
540
        c.JSON(http.StatusOK, response)
541
        return
542
    }
543

544
    // Get the stored redirect URI from session
545
3x
    storedRedirectURI := session.Get("oauth_redirect_uri")
546
3x
    var redirectURI string
547
3x
    if storedRedirectURI != nil {
548
1x
        redirectURI = storedRedirectURI.(string)
549
1x
    }
550

551
    // Clear the state and redirect URI from session
552
3x
    session.Delete("oauth_state")
553
3x
    session.Delete("oauth_redirect_uri")
554
3x
    if err := session.Save(); err != nil {
555
        h.logger.Error(c.Request.Context(), "Failed to save session", err, map[string]interface{}{"error": err.Error()})
556
        HandleAppError(c, contextutils.WrapError(err, "failed to save session"))
557
        return
558
    }
559

560
    // Authenticate user with Google OAuth
561
3x
    user, err := h.oauthService.AuthenticateGoogleUser(c.Request.Context(), code, h.userService)
562
3x
    if err != nil {
563
1x
        h.logger.Error(c.Request.Context(), "Google OAuth authentication failed", err, map[string]interface{}{"error": err.Error()})
564
1x

565
1x
        // Check if this is a signup disabled error (structured)
566
1x
        if errors.Is(err, services.ErrSignupsDisabled) {
567
1x
            span.SetAttributes(attribute.Bool("oauth.signups_disabled", true))
568
1x
            HandleAppError(c, contextutils.ErrForbidden)
569
1x
            return
570
1x
        }
571

572
        // Provide better error messages to the frontend using structured error checking
573
        errorMessage := "Authentication failed"
574
        if errors.Is(err, services.ErrOAuthCodeAlreadyUsed) {
575
            errorMessage = "This authentication link has already been used. Please try signing in again."
576
        } else if errors.Is(err, services.ErrOAuthClientConfig) {
577
            errorMessage = "OAuth configuration error. Please contact support."
578
        } else if errors.Is(err, services.ErrOAuthInvalidRequest) {
579
            errorMessage = "Invalid authentication request. Please try again."
580
        } else if errors.Is(err, services.ErrOAuthUnauthorized) {
581
            errorMessage = "OAuth client is not authorized. Please contact support."
582
        } else if errors.Is(err, services.ErrOAuthUnsupportedGrant) {
583
            errorMessage = "Unsupported OAuth grant type. Please contact support."
584
        }
585

586
        HandleAppError(c, contextutils.WrapError(err, errorMessage))
587
        return
588
    }
589

590
    // Update span attributes with user info
591
2x
    span.SetAttributes(
592
2x
        attribute.Int("user.id", user.ID),
593
2x
        attribute.String("user.username", user.Username),
594
2x
        attribute.Bool("user.email_provided", user.Email.Valid),
595
2x
        attribute.String("user.language", user.PreferredLanguage.String),
596
2x
        attribute.String("user.level", user.CurrentLevel.String),
597
2x
        attribute.Bool("user.is_new", user.CreatedAt.After(time.Now().Add(-5*time.Minute))), // Rough check if user was just created
598
2x
    )
599
2x

600
2x
    // Update last active
601
2x
    if err := h.userService.UpdateLastActive(c.Request.Context(), user.ID); err != nil {
602
        h.logger.Warn(c.Request.Context(), "Failed to update last active for user", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
603
    }
604

605
    // Create session
606
2x
    session.Set(middleware.UserIDKey, user.ID)
607
2x
    session.Set(middleware.UsernameKey, user.Username)
608
2x

609
2x
    h.logger.Info(c.Request.Context(), "Setting session for user", map[string]interface{}{"user_id": user.ID, "username": user.Username})
610
2x

611
2x
    if err := session.Save(); err != nil {
612
        h.logger.Error(c.Request.Context(), "Failed to save session", err, map[string]interface{}{"error": err.Error()})
613
        HandleAppError(c, contextutils.WrapError(err, "failed to create session"))
614
        return
615
    }
616

617
    // Convert models.User to api.User with proper API key checking
618
2x
    apiUser := convertUserToAPIWithService(c.Request.Context(), user, h.userService)
619
2x

620
2x
    h.logger.Info(c.Request.Context(), "Google OAuth successful for user", map[string]interface{}{"username": user.Username, "user_id": user.ID})
621
2x

622
2x
    // Return user info with redirect URI if available
623
2x
    response := api.LoginResponse{
624
2x
        Success: boolPtr(true),
625
2x
        Message: stringPtr("Google authentication successful"),
626
2x
        User:    &apiUser,
627
2x
    }
628
2x

629
2x
    // Add redirect URI to response if it was stored
630
2x
    if redirectURI != "" {
631
1x
        response.RedirectUri = &redirectURI
632
1x
    }
633

634
2x
    c.JSON(http.StatusOK, response)
635
}
636

637
// generateRandomState generates a cryptographically secure random state parameter for OAuth security
638
1122x
func generateRandomState() string {
639
1122x
    const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
640
1122x
    b := make([]byte, 32)
641
1122x

642
1122x
    // Use crypto/rand for cryptographically secure random generation
643
1122x
    for i := range b {
644
35904x
        // Generate a random byte and map it to charset
645
35904x
        randomByte := make([]byte, 1)
646
35904x
        if _, err := rand.Read(randomByte); err != nil {
647
            // If crypto/rand fails, we have a serious system issue - don't fallback to weaker randomness
648
            panic("Cryptographic random number generation failed: " + err.Error())
649
        }
650
35904x
        b[i] = charset[randomByte[0]%byte(len(charset))]
651
    }
652
1122x
    return string(b)
653
}
654

655
// SignupStatus returns whether signups are enabled or disabled
656
4x
func (h *AuthHandler) SignupStatus(c *gin.Context) {
657
4x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "signup_status")
658
4x
    defer observability.FinishSpan(span, nil)
659
4x

660
4x
    signupsDisabled := false
661
4x
    oauthWhitelistEnabled := false
662
4x
    var allowedDomains []string
663
4x
    var allowedEmails []string
664
4x

665
4x
    if h.config != nil {
666
4x
        signupsDisabled = h.config.IsSignupDisabled()
667
4x
        if h.config.System != nil {
668
3x
            oauthWhitelistEnabled = len(h.config.System.Auth.AllowedDomains) > 0 || len(h.config.System.Auth.AllowedEmails) > 0
669
3x
            allowedDomains = h.config.System.Auth.AllowedDomains
670
3x
            allowedEmails = h.config.System.Auth.AllowedEmails
671
3x
        }
672
    }
673

674
4x
    span.SetAttributes(
675
4x
        attribute.Bool("auth.signups_disabled", signupsDisabled),
676
4x
        attribute.Bool("auth.config_available", h.config != nil),
677
4x
        attribute.Bool("auth.oauth_whitelist_enabled", oauthWhitelistEnabled),
678
4x
    )
679
4x

680
4x
    c.JSON(http.StatusOK, gin.H{
681
4x
        "signups_disabled":        signupsDisabled,
682
4x
        "oauth_whitelist_enabled": oauthWhitelistEnabled,
683
4x
        "allowed_domains":         allowedDomains,
684
4x
        "allowed_emails":          allowedEmails,
685
4x
    })
686
}
687


			
quizapp internal handlers worker_admin_handler.go
86.4%
Statements
19/22
1
package handlers
2

3
import (
4
    "context"
5
    "errors"
6

7
    "quizapp/internal/middleware"
8

9
    "github.com/gin-contrib/sessions"
10
    "github.com/gin-gonic/gin"
11
)
12

13
var (
14
    // ErrUnauthenticated indicates no current user could be determined
15
    ErrUnauthenticated = errors.New("user not authenticated")
16
    // ErrInvalidUserID indicates the stored user identifier is malformed
17
    ErrInvalidUserID = errors.New("invalid user id")
18
    // ErrForbidden indicates the user lacks permissions for the operation
19
    ErrForbidden = errors.New("forbidden")
20
)
21

22
// GetCurrentUserID returns the current authenticated user's ID.
23
// It first checks the Gin context (set by RequireAuth/RequireAdmin),
24
// then falls back to the session store. Returns an error if unauthenticated
25
// or if the stored value is invalid.
26
13x
func GetCurrentUserID(c *gin.Context) (int, error) {
27
13x
    if rawID, exists := c.Get(middleware.UserIDKey); exists {
28
9x
        if id, ok := rawID.(int); ok {
29
7x
            return id, nil
30
7x
        }
31
1x
        return 0, ErrInvalidUserID
32
    }
33

34
    // Fallback to session lookup if context not populated
35
2x
    session := sessions.Default(c)
36
2x
    userID := session.Get(middleware.UserIDKey)
37
2x
    if userID == nil {
38
1x
        return 0, ErrUnauthenticated
39
1x
    }
40
1x
    id, ok := userID.(int)
41
1x
    if !ok {
42
        return 0, ErrInvalidUserID
43
    }
44
1x
    return id, nil
45
}
46

47
// authzAdminChecker is the minimal capability needed from user service for admin checks.
48
// Any concrete user service that implements IsAdmin satisfies this interface.
49
type authzAdminChecker interface {
50
    IsAdmin(ctx context.Context, userID int) (bool, error)
51
}
52

53
// RequireSelfOrAdmin permits the action if the current user is the target user
54
// or has admin privileges. Returns ErrForbidden when neither condition is met.
55
9x
func RequireSelfOrAdmin(ctx context.Context, svc authzAdminChecker, currentID, targetID int) error {
56
9x
    if currentID == 0 {
57
        return ErrUnauthenticated
58
    }
59
9x
    if currentID == targetID {
60
4x
        return nil
61
4x
    }
62

63
5x
    isAdmin, err := svc.IsAdmin(ctx, currentID)
64
5x
    if err != nil {
65
        return err
66
    }
67
5x
    if !isAdmin {
68
1x
        return ErrForbidden
69
1x
    }
70
3x
    return nil
71
}
72


			
quizapp internal handlers worker_admin_handler.go
89.2%
Statements
181/203
1
package handlers
2

3
import (
4
    "context"
5
    "encoding/json"
6
    "time"
7

8
    "quizapp/internal/api"
9
    "quizapp/internal/models"
10
    "quizapp/internal/services"
11
    contextutils "quizapp/internal/utils"
12

13
    openapi_types "github.com/oapi-codegen/runtime/types"
14
)
15

16
// Helper functions for pointer conversion
17
318x
func stringPtr(s string) *string {
18
318x
    return &s
19
318x
}
20

21
136x
func boolPtr(b bool) *bool {
22
136x
    return &b
23
136x
}
24

25
305x
func int64Ptr(i int) *int64 {
26
305x
    i64 := int64(i)
27
305x
    return &i64
28
305x
}
29

30
173x
func float32Ptr(f float32) *float32 {
31
173x
    return &f
32
173x
}
33

34
205x
func intPtr(i int) *int {
35
205x
    return &i
36
205x
}
37

38
// formatTimePtr formats a time.Time into an RFC3339 string pointer
39
142x
func formatTimePtr(t time.Time) *string {
40
142x
    s := t.In(time.UTC).Format(time.RFC3339)
41
142x
    return &s
42
142x
}
43

44
// formatTimePointer converts a *time.Time to *string (RFC3339) or nil
45
142x
func formatTimePointer(tp *time.Time) *string {
46
142x
    if tp == nil {
47
18x
        return nil
48
18x
    }
49
124x
    s := tp.In(time.UTC).Format(time.RFC3339)
50
124x
    return &s
51
}
52

53
// formatTime formats a time.Time into an RFC3339 string
54
145x
func formatTime(t time.Time) string {
55
145x
    return t.In(time.UTC).Format(time.RFC3339)
56
145x
}
57

58
// Convert models.User to api.User
59
134x
func convertUserToAPI(user *models.User) api.User {
60
134x
    apiUser := api.User{
61
134x
        Id:       int64Ptr(user.ID),
62
134x
        Username: stringPtr(user.Username),
63
134x
    }
64
134x

65
134x
    if !user.CreatedAt.IsZero() {
66
124x
        apiUser.CreatedAt = formatTimePtr(user.CreatedAt)
67
124x
    }
68

69
134x
    if user.LastActive.Valid {
70
124x
        apiUser.LastActive = formatTimePointer(&user.LastActive.Time)
71
124x
    }
72

73
134x
    if user.Email.Valid {
74
61x
        s := user.Email.String
75
61x
        apiUser.Email = &s
76
61x
    }
77

78
134x
    if user.Timezone.Valid {
79
124x
        s := user.Timezone.String
80
124x
        apiUser.Timezone = &s
81
124x
    }
82

83
134x
    if user.PreferredLanguage.Valid {
84
132x
        s := user.PreferredLanguage.String
85
132x
        apiUser.PreferredLanguage = &s
86
132x
    }
87

88
134x
    if user.CurrentLevel.Valid {
89
132x
        s := user.CurrentLevel.String
90
132x
        apiUser.CurrentLevel = &s
91
132x
    }
92

93
134x
    if user.AIProvider.Valid {
94
57x
        s := user.AIProvider.String
95
57x
        apiUser.AiProvider = &s
96
57x
    }
97

98
134x
    if user.AIModel.Valid {
99
57x
        s := user.AIModel.String
100
57x
        apiUser.AiModel = &s
101
57x
    }
102

103
    // Always set ai_enabled as a boolean (never null)
104
134x
    aiEnabled := user.AIEnabled.Valid && user.AIEnabled.Bool
105
134x
    apiUser.AiEnabled = &aiEnabled
106
134x

107
134x
    // For backwards compatibility, we'll set has_api_key to false here
108
134x
    // The proper check should be done using convertUserToAPIWithService
109
134x
    hasAPIKey := false
110
134x
    apiUser.HasApiKey = &hasAPIKey
111
134x

112
134x
    // Include user roles if they exist
113
134x
    if len(user.Roles) > 0 {
114
        apiRoles := make([]api.Role, len(user.Roles))
115
        for i, role := range user.Roles {
116
            apiRoles[i] = api.Role{
117
                Id:          int64(role.ID),
118
                Name:        role.Name,
119
                Description: role.Description,
120
                CreatedAt:   formatTime(role.CreatedAt),
121
                UpdatedAt:   formatTime(role.UpdatedAt),
122
            }
123
        }
124
        apiUser.Roles = &apiRoles
125
    }
126

127
134x
    return apiUser
128
}
129

130
// convertUserToAPIWithService converts a models.User to api.User with proper API key checking
131
134x
func convertUserToAPIWithService(ctx context.Context, user *models.User, userService services.UserServiceInterface) api.User {
132
134x
    apiUser := convertUserToAPI(user)
133
134x

134
134x
    // Check if user has a valid API key for their current provider using the new table
135
134x
    hasAPIKey := false
136
134x
    if user.AIProvider.Valid && user.AIProvider.String != "" {
137
57x
        // Use the new per-provider API key system instead of the old user.AIAPIKey field
138
57x
        if userService != nil {
139
57x
            savedKey, err := userService.GetUserAPIKey(ctx, user.ID, user.AIProvider.String)
140
57x
            if err == nil && savedKey != "" {
141
                // API key is available but not exposed in the API response for security
142
                hasAPIKey = true
143
            }
144
        }
145
    }
146
    // If user doesn't have an AI provider set, hasAPIKey remains false (default)
147
134x
    apiUser.HasApiKey = &hasAPIKey
148
134x

149
134x
    return apiUser
150
}
151

152
// Convert models.Question to api.Question
153
145x
func convertQuestionToAPI(question *models.Question) api.Question {
154
145x
    apiQuestion := api.Question{
155
145x
        Id:              int64Ptr(question.ID),
156
145x
        DifficultyScore: float32Ptr(float32(question.DifficultyScore)),
157
145x
        CorrectAnswer:   intPtr(question.CorrectAnswer),
158
145x
        // UsageCount removed; use total_responses instead
159
145x
    }
160
145x

161
145x
    if !question.CreatedAt.IsZero() {
162
145x
        v := formatTime(question.CreatedAt)
163
145x
        apiQuestion.CreatedAt = &v
164
145x
    }
165

166
145x
    if question.Type != "" {
167
145x
        qType := api.QuestionType(question.Type)
168
145x
        apiQuestion.Type = &qType
169
145x
    }
170

171
145x
    if question.Language != "" {
172
145x
        lang := api.Language(question.Language)
173
145x
        apiQuestion.Language = &lang
174
145x
    }
175

176
145x
    if question.Level != "" {
177
145x
        level := api.Level(question.Level)
178
145x
        apiQuestion.Level = &level
179
145x
    }
180

181
145x
    if question.Explanation != "" {
182
145x
        apiQuestion.Explanation = &question.Explanation
183
145x
    }
184

185
145x
    if question.Status != "" {
186
145x
        status := api.QuestionStatus(question.Status)
187
145x
        apiQuestion.Status = &status
188
145x
    }
189

190
    // Convert content map to api.QuestionContent
191
145x
    if question.Content != nil {
192
145x
        content := &api.QuestionContent{}
193
145x

194
145x
        if q, ok := question.Content["question"].(string); ok {
195
144x
            content.Question = q
196
144x
        }
197
145x
        if hint, ok := question.Content["hint"].(string); ok {
198
            content.Hint = &hint
199
        }
200
145x
        if passage, ok := question.Content["passage"].(string); ok {
201
            content.Passage = &passage
202
        }
203
145x
        if sentence, ok := question.Content["sentence"].(string); ok {
204
            content.Sentence = &sentence
205
        }
206
145x
        if opts, ok := question.Content["options"].([]interface{}); ok {
207
144x
            var options []string
208
144x
            for _, opt := range opts {
209
576x
                if o, ok := opt.(string); ok {
210
576x
                    options = append(options, o)
211
576x
                }
212
            }
213
144x
            if len(options) > 0 {
214
144x
                content.Options = options
215
144x
            }
216
        }
217
145x
        apiQuestion.Content = content
218
    }
219

220
    // Add variety elements to the API response
221
145x
    if question.TopicCategory != "" {
222
142x
        apiQuestion.TopicCategory = &question.TopicCategory
223
142x
    }
224
145x
    if question.GrammarFocus != "" {
225
141x
        apiQuestion.GrammarFocus = &question.GrammarFocus
226
141x
    }
227
145x
    if question.VocabularyDomain != "" {
228
141x
        apiQuestion.VocabularyDomain = &question.VocabularyDomain
229
141x
    }
230
145x
    if question.Scenario != "" {
231
141x
        apiQuestion.Scenario = &question.Scenario
232
141x
    }
233
145x
    if question.StyleModifier != "" {
234
141x
        apiQuestion.StyleModifier = &question.StyleModifier
235
141x
    }
236
145x
    if question.DifficultyModifier != "" {
237
141x
        apiQuestion.DifficultyModifier = &question.DifficultyModifier
238
141x
    }
239
145x
    if question.TimeContext != "" {
240
141x
        apiQuestion.TimeContext = &question.TimeContext
241
141x
    }
242

243
145x
    return apiQuestion
244
}
245

246
// Convert services.QuestionWithStats to a JSON-compatible map using generated
247
// api.Question for fields, and include any additional fields the frontend
248
// expects (e.g., report_reasons) that are not present on the generated type.
249
1x
func convertQuestionWithStatsToAPIMap(q *services.QuestionWithStats) map[string]interface{} {
250
1x
    apiQ := api.Question{}
251
1x
    if q != nil && q.Question != nil {
252
1x
        apiQ = convertQuestionToAPI(q.Question)
253
1x
    }
254

255
    // Attach stats
256
1x
    if q != nil {
257
1x
        apiQ.CorrectCount = intPtr(q.CorrectCount)
258
1x
        apiQ.IncorrectCount = intPtr(q.IncorrectCount)
259
1x
        apiQ.TotalResponses = intPtr(q.TotalResponses)
260
1x
        apiQ.UserCount = intPtr(q.UserCount)
261
1x
        if q.Reporters != "" {
262
1x
            apiQ.Reporters = &q.Reporters
263
1x
        }
264
        // ConfidenceLevel is optional
265
1x
        if q.ConfidenceLevel != nil {
266
            apiQ.ConfidenceLevel = q.ConfidenceLevel
267
        }
268
    }
269

270
    // Marshal to generic map so we can add fields not present in api.Question
271
1x
    m := map[string]interface{}{}
272
1x
    if b, err := json.Marshal(apiQ); err == nil {
273
1x
        _ = json.Unmarshal(b, &m)
274
1x
    }
275

276
    // Add report_reasons if available on the service struct
277
1x
    if q != nil && q.ReportReasons != "" {
278
1x
        m["report_reasons"] = q.ReportReasons
279
1x
    }
280

281
1x
    return m
282
}
283

284
// Convert models.UserProgress to api.UserProgress
285
18x
func convertUserProgressToAPI(ctx context.Context, progress *models.UserProgress, userID int, userLookup func(context.Context, int) (*models.User, error)) api.UserProgress {
286
18x
    apiProgress := api.UserProgress{
287
18x
        TotalQuestions: intPtr(progress.TotalQuestions),
288
18x
        CorrectAnswers: intPtr(progress.CorrectAnswers),
289
18x
        AccuracyRate:   float32Ptr(float32(progress.AccuracyRate / 100.0)),
290
18x
    }
291
18x

292
18x
    if progress.CurrentLevel != "" {
293
18x
        level := api.Level(progress.CurrentLevel)
294
18x
        apiProgress.CurrentLevel = &level
295
18x
    }
296

297
18x
    if progress.SuggestedLevel != "" {
298
        level := api.Level(progress.SuggestedLevel)
299
        apiProgress.SuggestedLevel = &level
300
    }
301

302
18x
    if progress.WeakAreas != nil {
303
4x
        apiProgress.WeakAreas = &progress.WeakAreas
304
4x
    }
305

306
    // Convert performance metrics
307
18x
    if progress.PerformanceByTopic != nil {
308
18x
        perfMap := make(map[string]api.PerformanceMetrics)
309
18x
        for topic, metrics := range progress.PerformanceByTopic {
310
10x
            if metrics != nil {
311
10x
                perfMap[topic] = api.PerformanceMetrics{
312
10x
                    TotalAttempts:         intPtr(metrics.TotalAttempts),
313
10x
                    CorrectAttempts:       intPtr(metrics.CorrectAttempts),
314
10x
                    AverageResponseTimeMs: float32Ptr(float32(metrics.AverageResponseTimeMs)),
315
10x
                    LastUpdated: func() *string {
316
10x
                        if metrics.LastUpdated.IsZero() {
317
                            return nil
318
                        }
319
10x
                        s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, metrics.LastUpdated, time.RFC3339, userLookup)
320
10x
                        if err != nil || s == "" {
321
                            tmp := metrics.LastUpdated.In(time.UTC).Format(time.RFC3339)
322
                            return &tmp
323
                        }
324
10x
                        return &s
325
                    }(),
326
                }
327
            }
328
        }
329
18x
        apiProgress.PerformanceByTopic = &perfMap
330
    }
331

332
    // Convert recent activity
333
18x
    if progress.RecentActivity != nil {
334
10x
        var recentActivity []api.UserResponse
335
10x
        for _, activity := range progress.RecentActivity {
336
26x
            apiActivity := api.UserResponse{
337
26x
                QuestionId: int64Ptr(activity.QuestionID),
338
26x
                IsCorrect:  &activity.IsCorrect,
339
26x
            }
340
26x
            if !activity.CreatedAt.IsZero() {
341
26x
                s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, activity.CreatedAt, time.RFC3339, userLookup)
342
26x
                if err != nil || s == "" {
343
                    tmp := activity.CreatedAt.In(time.UTC).Format(time.RFC3339)
344
                    apiActivity.CreatedAt = &tmp
345
                } else {
346
26x
                    apiActivity.CreatedAt = &s
347
26x
                }
348
            }
349
26x
            recentActivity = append(recentActivity, apiActivity)
350
        }
351
10x
        apiProgress.RecentActivity = &recentActivity
352
    }
353

354
18x
    return apiProgress
355
}
356

357
// Convert models.DailyQuestionAssignmentWithQuestion to api.DailyQuestionWithDetails
358
140x
func convertDailyAssignmentToAPI(ctx context.Context, assignment *models.DailyQuestionAssignmentWithQuestion, userID int, userLookup func(context.Context, int) (*models.User, error)) api.DailyQuestionWithDetails {
359
140x
    var completedAt *string
360
140x
    if assignment.CompletedAt.Valid {
361
7x
        if s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, assignment.CompletedAt.Time, time.RFC3339, userLookup); err == nil && s != "" {
362
7x
            completedAt = &s
363
7x
        } else {
364
            tmp := assignment.CompletedAt.Time.In(time.UTC).Format(time.RFC3339)
365
            completedAt = &tmp
366
        }
367
    }
368

369
140x
    apiQuestion := api.Question{}
370
140x
    if assignment.Question != nil {
371
140x
        apiQuestion = convertQuestionToAPI(assignment.Question)
372
140x
        // Override total_responses so UI 'Shown' reflects Daily-only impressions
373
140x
        if assignment.DailyShownCount > 0 {
374
140x
            apiQuestion.TotalResponses = &assignment.DailyShownCount
375
140x
        }
376
    }
377

378
    // AssignmentDate: produce date-only value (YYYY-MM-DD) using openapi_types.Date
379
140x
    ad := assignment.AssignmentDate
380
140x
    assignDate := openapi_types.Date{Time: ad}
381
140x

382
140x
    // CreatedAt in user's timezone (with error-checked fallback)
383
140x
    var createdStr string
384
140x
    if s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, assignment.CreatedAt, time.RFC3339, userLookup); err == nil && s != "" {
385
140x
        createdStr = s
386
140x
    } else {
387
        createdStr = assignment.CreatedAt.In(time.UTC).Format(time.RFC3339)
388
    }
389

390
140x
    var submittedAt *string
391
140x
    if assignment.SubmittedAt != nil {
392
7x
        if s, _, err := contextutils.FormatTimeInUserTimezone(ctx, userID, *assignment.SubmittedAt, time.RFC3339, userLookup); err == nil && s != "" {
393
7x
            submittedAt = &s
394
7x
        } else {
395
            tmp := assignment.SubmittedAt.In(time.UTC).Format(time.RFC3339)
396
            submittedAt = &tmp
397
        }
398
    }
399

400
140x
    result := api.DailyQuestionWithDetails{
401
140x
        Id:              int64(assignment.ID),
402
140x
        UserId:          int64(assignment.UserID),
403
140x
        QuestionId:      int64(assignment.QuestionID),
404
140x
        AssignmentDate:  assignDate,
405
140x
        IsCompleted:     assignment.IsCompleted,
406
140x
        CompletedAt:     completedAt,
407
140x
        CreatedAt:       createdStr,
408
140x
        UserAnswerIndex: assignment.UserAnswerIndex,
409
140x
        SubmittedAt:     submittedAt,
410
140x
        Question:        apiQuestion,
411
140x
    }
412
140x

413
140x
    // Attach per-user stats when available
414
140x
    if assignment.DailyShownCount >= 0 {
415
140x
        shown := int64(assignment.DailyShownCount)
416
140x
        result.UserShownCount = &shown
417
140x
    }
418
140x
    if assignment.UserTotalResponses >= 0 {
419
140x
        total := int64(assignment.UserTotalResponses)
420
140x
        result.UserTotalResponses = &total
421
140x
    }
422
140x
    if assignment.UserCorrectCount >= 0 {
423
140x
        cc := int64(assignment.UserCorrectCount)
424
140x
        result.UserCorrectCount = &cc
425
140x
    }
426
140x
    if assignment.UserIncorrectCount >= 0 {
427
140x
        ic := int64(assignment.UserIncorrectCount)
428
140x
        result.UserIncorrectCount = &ic
429
140x
    }
430

431
140x
    return result
432
}
433

434
// Convert slice of assignments
435
14x
func convertDailyAssignmentsToAPI(ctx context.Context, assignments []*models.DailyQuestionAssignmentWithQuestion, userID int, userLookup func(context.Context, int) (*models.User, error)) []api.DailyQuestionWithDetails {
436
14x
    if len(assignments) == 0 {
437
        return []api.DailyQuestionWithDetails{}
438
    }
439
14x
    apiAssignments := make([]api.DailyQuestionWithDetails, len(assignments))
440
14x
    for i, a := range assignments {
441
140x
        apiAssignments[i] = convertDailyAssignmentToAPI(ctx, a, userID, userLookup)
442
140x
    }
443
14x
    return apiAssignments
444
}
445

446
// Convert models.DailyProgress to api.DailyProgress
447
4x
func convertDailyProgressToAPI(progress *models.DailyProgress) api.DailyProgress {
448
4x
    return api.DailyProgress{
449
4x
        Date:      openapi_types.Date{Time: progress.Date},
450
4x
        Completed: progress.Completed,
451
4x
        Total:     progress.Total,
452
4x
    }
453
4x
}
454


			
quizapp internal handlers worker_admin_handler.go
59.3%
Statements
147/248
1
package handlers
2

3
import (
4
    "context"
5
    "net/http"
6
    "strconv"
7
    "strings"
8
    "time"
9

10
    "quizapp/internal/api"
11
    "quizapp/internal/config"
12
    "quizapp/internal/observability"
13
    "quizapp/internal/services"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/gin-gonic/gin"
17
    "go.opentelemetry.io/otel/attribute"
18
    "go.opentelemetry.io/otel/codes"
19
    "go.opentelemetry.io/otel/trace"
20
)
21

22
// DailyQuestionHandler handles daily question-related HTTP requests
23
type DailyQuestionHandler struct {
24
    userService          services.UserServiceInterface
25
    dailyQuestionService services.DailyQuestionServiceInterface
26
    cfg                  *config.Config
27
    logger               *observability.Logger
28
}
29

30
// NewDailyQuestionHandler creates a new DailyQuestionHandler
31
func NewDailyQuestionHandler(
32
    userService services.UserServiceInterface,
33
    dailyQuestionService services.DailyQuestionServiceInterface,
34
    cfg *config.Config,
35
    logger *observability.Logger,
36
15x
) *DailyQuestionHandler {
37
15x
    return &DailyQuestionHandler{
38
15x
        userService:          userService,
39
15x
        dailyQuestionService: dailyQuestionService,
40
15x
        cfg:                  cfg,
41
15x
        logger:               logger,
42
15x
    }
43
15x
}
44

45
// ParseDateInUserTimezone parses a date string in the user's timezone
46
42x
func (h *DailyQuestionHandler) ParseDateInUserTimezone(ctx context.Context, userID int, dateStr string) (time.Time, string, error) {
47
42x
    // Delegate to shared util with injected user lookup
48
42x
    return contextutils.ParseDateInUserTimezone(ctx, userID, dateStr, h.userService.GetUserByID)
49
42x
}
50

51
// GetDailyQuestions handles GET /v1/daily/questions/{date}
52
15x
func (h *DailyQuestionHandler) GetDailyQuestions(c *gin.Context) {
53
15x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_daily_questions")
54
15x
    defer observability.FinishSpan(span, nil)
55
15x

56
15x
    userID, exists := GetUserIDFromSession(c)
57
15x
    if !exists {
58
        HandleAppError(c, contextutils.ErrUnauthorized)
59
        return
60
    }
61

62
    // Parse date parameter
63
15x
    dateStr := c.Param("date")
64
15x
    if dateStr == "" {
65
        HandleAppError(c, contextutils.ErrMissingRequired)
66
        return
67
    }
68

69
    // Parse date in user's timezone
70
15x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
71
15x
    if err != nil {
72
1x
        // Check if it's an invalid date format error
73
1x
        if strings.Contains(err.Error(), "invalid date format") {
74
1x
            HandleAppError(c, contextutils.ErrInvalidFormat)
75
1x
            return
76
1x
        }
77
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
78
        return
79
    }
80

81
    // Add span attributes for observability
82
14x
    span.SetAttributes(
83
14x
        observability.AttributeUserID(userID),
84
14x
        attribute.String("date", dateStr),
85
14x
        attribute.String("timezone", timezone),
86
14x
    )
87
14x

88
14x
    // Get daily questions for the date
89
14x
    questions, err := h.dailyQuestionService.GetDailyQuestions(ctx, userID, date)
90
14x
    if err != nil {
91
        h.logger.Error(ctx, "Failed to get daily questions", err, map[string]interface{}{
92
            "user_id":  userID,
93
            "date":     dateStr,
94
            "timezone": timezone,
95
        })
96
        HandleAppError(c, contextutils.WrapError(err, "failed to get daily questions"))
97
        return
98
    }
99

100
    // Convert to API types using shared converter
101
14x
    apiQuestions := convertDailyAssignmentsToAPI(ctx, questions, userID, h.userService.GetUserByID)
102
14x

103
14x
    c.JSON(http.StatusOK, gin.H{
104
14x
        "questions": apiQuestions,
105
14x
        "date":      dateStr,
106
14x
    })
107
}
108

109
// MarkQuestionCompleted handles POST /v1/daily/questions/{date}/complete/{questionId}
110
6x
func (h *DailyQuestionHandler) MarkQuestionCompleted(c *gin.Context) {
111
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "mark_daily_question_completed")
112
6x
    defer observability.FinishSpan(span, nil)
113
6x

114
6x
    userID, exists := GetUserIDFromSession(c)
115
6x
    if !exists {
116
        HandleAppError(c, contextutils.ErrUnauthorized)
117
        return
118
    }
119

120
    // Parse parameters
121
6x
    dateStr := c.Param("date")
122
6x
    questionIDStr := c.Param("questionId")
123
6x

124
6x
    if dateStr == "" || questionIDStr == "" {
125
        HandleAppError(c, contextutils.ErrMissingRequired)
126
        return
127
    }
128

129
    // Parse date in user's timezone
130
6x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
131
6x
    if err != nil {
132
        // Check if it's an invalid date format error
133
        if strings.Contains(err.Error(), "invalid date format") {
134
            HandleAppError(c, contextutils.ErrInvalidFormat)
135
            return
136
        }
137
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
138
        return
139
    }
140

141
6x
    questionID, err := strconv.Atoi(questionIDStr)
142
6x
    if err != nil {
143
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
144
1x
        return
145
1x
    }
146

147
    // Add span attributes for observability
148
5x
    span.SetAttributes(
149
5x
        observability.AttributeUserID(userID),
150
5x
        attribute.String("date", dateStr),
151
5x
        attribute.Int("question_id", questionID),
152
5x
        attribute.String("timezone", timezone),
153
5x
    )
154
5x

155
5x
    // Mark question as completed
156
5x
    err = h.dailyQuestionService.MarkQuestionCompleted(ctx, userID, questionID, date)
157
5x
    if err != nil {
158
        h.logger.Error(ctx, "Failed to mark daily question as completed", err, map[string]interface{}{
159
            "user_id":     userID,
160
            "question_id": questionID,
161
            "date":        dateStr,
162
            "timezone":    timezone,
163
        })
164

165
        // Check if the error indicates no assignment was found
166
        if contextutils.IsError(err, contextutils.ErrAssignmentNotFound) {
167
            HandleAppError(c, contextutils.ErrAssignmentNotFound)
168
            return
169
        }
170

171
        HandleAppError(c, contextutils.WrapError(err, "failed to mark question as completed"))
172
        return
173
    }
174

175
5x
    c.JSON(http.StatusOK, api.SuccessResponse{
176
5x
        Message: stringPtr("Question marked as completed"),
177
5x
    })
178
}
179

180
// ResetQuestionCompleted handles DELETE /v1/daily/questions/{date}/complete/{questionId}
181
3x
func (h *DailyQuestionHandler) ResetQuestionCompleted(c *gin.Context) {
182
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "reset_daily_question_completed")
183
3x
    defer observability.FinishSpan(span, nil)
184
3x

185
3x
    userID, exists := GetUserIDFromSession(c)
186
3x
    if !exists {
187
        HandleAppError(c, contextutils.ErrUnauthorized)
188
        return
189
    }
190

191
    // Parse parameters
192
3x
    dateStr := c.Param("date")
193
3x
    questionIDStr := c.Param("questionId")
194
3x

195
3x
    if dateStr == "" || questionIDStr == "" {
196
        HandleAppError(c, contextutils.ErrMissingRequired)
197
        return
198
    }
199

200
    // Parse date in user's timezone
201
3x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
202
3x
    if err != nil {
203
        // Check if it's an invalid date format error
204
        if strings.Contains(err.Error(), "invalid date format") {
205
            HandleAppError(c, contextutils.ErrInvalidFormat)
206
            return
207
        }
208
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
209
        return
210
    }
211

212
3x
    questionID, err := strconv.Atoi(questionIDStr)
213
3x
    if err != nil {
214
        HandleAppError(c, contextutils.ErrInvalidFormat)
215
        return
216
    }
217

218
    // Add span attributes for observability
219
3x
    span.SetAttributes(
220
3x
        observability.AttributeUserID(userID),
221
3x
        attribute.String("date", dateStr),
222
3x
        attribute.Int("question_id", questionID),
223
3x
        attribute.String("timezone", timezone),
224
3x
    )
225
3x

226
3x
    // Reset question completion status
227
3x
    err = h.dailyQuestionService.ResetQuestionCompleted(ctx, userID, questionID, date)
228
3x
    if err != nil {
229
        h.logger.Error(ctx, "Failed to reset daily question completion", err, map[string]interface{}{
230
            "user_id":     userID,
231
            "question_id": questionID,
232
            "date":        dateStr,
233
            "timezone":    timezone,
234
        })
235

236
        // Check if the error indicates no assignment was found
237
        if contextutils.IsError(err, contextutils.ErrAssignmentNotFound) {
238
            HandleAppError(c, contextutils.ErrAssignmentNotFound)
239
            return
240
        }
241

242
        HandleAppError(c, contextutils.WrapError(err, "failed to reset question completion"))
243
        return
244
    }
245

246
3x
    c.JSON(http.StatusOK, api.SuccessResponse{
247
3x
        Message: stringPtr("Question completion reset"),
248
3x
    })
249
}
250

251
// GetAvailableDates handles GET /v1/daily/dates
252
1x
func (h *DailyQuestionHandler) GetAvailableDates(c *gin.Context) {
253
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_daily_available_dates")
254
1x
    defer observability.FinishSpan(span, nil)
255
1x

256
1x
    userID, exists := GetUserIDFromSession(c)
257
1x
    if !exists {
258
        HandleAppError(c, contextutils.ErrUnauthorized)
259
        return
260
    }
261

262
    // Add span attributes for observability
263
1x
    span.SetAttributes(observability.AttributeUserID(userID))
264
1x

265
1x
    // Get available dates with assignments
266
1x
    dates, err := h.dailyQuestionService.GetAvailableDates(ctx, userID)
267
1x
    if err != nil {
268
        h.logger.Error(ctx, "Failed to get available dates", err, map[string]interface{}{
269
            "user_id": userID,
270
        })
271
        HandleAppError(c, contextutils.WrapError(err, "failed to get available dates"))
272
        return
273
    }
274

275
    // Exclude future dates based on the user's timezone (clients expect local calendar days only)
276
1x
    user, _ := h.userService.GetUserByID(ctx, userID)
277
1x
    tz := "UTC"
278
1x
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
279
1x
        tz = user.Timezone.String
280
1x
    }
281
1x
    loc, err := time.LoadLocation(tz)
282
1x
    if err != nil {
283
        loc = time.UTC
284
    }
285
1x
    now := time.Now().In(loc)
286
1x
    today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
287
1x

288
1x
    // Filter out dates that are after today in the user's timezone
289
1x
    var filtered []time.Time
290
1x
    for _, d := range dates {
291
2x
        // Treat the date value as a date-only value (time component ignored)
292
2x
        if !d.After(today) {
293
2x
            filtered = append(filtered, d)
294
2x
        }
295
    }
296

297
    // Convert dates to string format for JSON response
298
1x
    dateStrings := make([]string, len(filtered))
299
1x
    for i, date := range filtered {
300
2x
        dateStrings[i] = date.Format("2006-01-02")
301
2x
    }
302

303
1x
    c.JSON(http.StatusOK, gin.H{
304
1x
        "dates": dateStrings,
305
1x
    })
306
}
307

308
// Note: Daily question assignment is now handled automatically by the worker
309
// when sending daily reminder emails. No manual assignment endpoint needed.
310

311
// GetDailyProgress handles GET /v1/daily/progress/{date}
312
4x
func (h *DailyQuestionHandler) GetDailyProgress(c *gin.Context) {
313
4x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_daily_progress")
314
4x
    defer observability.FinishSpan(span, nil)
315
4x

316
4x
    userID, exists := GetUserIDFromSession(c)
317
4x
    if !exists {
318
        HandleAppError(c, contextutils.ErrUnauthorized)
319
        return
320
    }
321

322
    // Parse date parameter
323
4x
    dateStr := c.Param("date")
324
4x
    if dateStr == "" {
325
        HandleAppError(c, contextutils.ErrMissingRequired)
326
        return
327
    }
328

329
    // Parse date in user's timezone
330
4x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
331
4x
    if err != nil {
332
        // Check if it's an invalid date format error
333
        if strings.Contains(err.Error(), "invalid date format") {
334
            HandleAppError(c, contextutils.ErrInvalidFormat)
335
            return
336
        }
337
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
338
        return
339
    }
340

341
    // Add span attributes for observability
342
4x
    span.SetAttributes(
343
4x
        observability.AttributeUserID(userID),
344
4x
        attribute.String("date", dateStr),
345
4x
        attribute.String("timezone", timezone),
346
4x
    )
347
4x

348
4x
    // Get daily progress for the date
349
4x
    progress, err := h.dailyQuestionService.GetDailyProgress(ctx, userID, date)
350
4x
    if err != nil {
351
        h.logger.Error(ctx, "Failed to get daily progress", err, map[string]interface{}{
352
            "user_id":  userID,
353
            "date":     dateStr,
354
            "timezone": timezone,
355
        })
356
        HandleAppError(c, contextutils.WrapError(err, "failed to get daily progress"))
357
        return
358
    }
359

360
    // Convert to API type using shared converter
361
4x
    apiProgress := convertDailyProgressToAPI(progress)
362
4x

363
4x
    c.JSON(http.StatusOK, apiProgress)
364
}
365

366
// SubmitDailyQuestionAnswer handles POST /v1/daily/questions/{date}/answer/{questionId}
367
9x
func (h *DailyQuestionHandler) SubmitDailyQuestionAnswer(c *gin.Context) {
368
9x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "submit_daily_question_answer")
369
9x
    defer observability.FinishSpan(span, nil)
370
9x

371
9x
    h.logger.Info(ctx, "SubmitDailyQuestionAnswer handler called", map[string]interface{}{
372
9x
        "method": c.Request.Method,
373
9x
        "path":   c.Request.URL.Path,
374
9x
        "params": c.Params,
375
9x
    })
376
9x

377
9x
    userID, exists := GetUserIDFromSession(c)
378
9x
    if !exists {
379
        HandleAppError(c, contextutils.ErrUnauthorized)
380
        return
381
    }
382

383
    // Parse parameters
384
9x
    dateStr := c.Param("date")
385
9x
    questionIDStr := c.Param("questionId")
386
9x

387
9x
    if dateStr == "" || questionIDStr == "" {
388
        HandleAppError(c, contextutils.ErrMissingRequired)
389
        return
390
    }
391

392
    // Parse date in user's timezone
393
9x
    date, timezone, err := h.ParseDateInUserTimezone(ctx, userID, dateStr)
394
9x
    if err != nil {
395
        // Check if it's an invalid date format error
396
        if strings.Contains(err.Error(), "invalid date format") {
397
            HandleAppError(c, contextutils.ErrInvalidFormat)
398
            return
399
        }
400
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
401
        return
402
    }
403

404
9x
    questionID, err := strconv.Atoi(questionIDStr)
405
9x
    if err != nil {
406
        HandleAppError(c, contextutils.ErrInvalidFormat)
407
        return
408
    }
409

410
    // Parse request body
411
9x
    var requestBody api.PostV1DailyQuestionsDateAnswerQuestionIdJSONBody
412
9x

413
9x
    h.logger.Info(ctx, "Parsing request body", map[string]interface{}{
414
9x
        "user_id":     userID,
415
9x
        "question_id": questionID,
416
9x
        "date":        dateStr,
417
9x
        "timezone":    timezone,
418
9x
    })
419
9x

420
9x
    if err := c.ShouldBindJSON(&requestBody); err != nil {
421
        h.logger.Error(ctx, "Failed to parse request body", err, map[string]interface{}{
422
            "user_id":     userID,
423
            "question_id": questionID,
424
            "date":        dateStr,
425
            "timezone":    timezone,
426
            "error":       err.Error(),
427
        })
428
        HandleAppError(c, contextutils.NewAppErrorWithCause(
429
            contextutils.ErrorCodeInvalidInput,
430
            contextutils.SeverityWarn,
431
            "Invalid request body",
432
            "",
433
            err,
434
        ))
435
        return
436
    }
437

438
9x
    h.logger.Info(ctx, "Request body parsed successfully",
439
9x
        map[string]interface{}{
440
9x
            "user_id":           userID,
441
9x
            "question_id":       questionID,
442
9x
            "date":              dateStr,
443
9x
            "timezone":          timezone,
444
9x
            "user_answer_index": requestBody.UserAnswerIndex,
445
9x
        })
446
9x

447
9x
    // Validate user answer index
448
9x
    if requestBody.UserAnswerIndex < 0 {
449
        h.logger.Warn(ctx, "Invalid user answer index in SubmitDailyQuestionAnswer", map[string]interface{}{"user_answer_index": requestBody.UserAnswerIndex})
450
        HandleAppError(c, contextutils.ErrInvalidAnswerIndex)
451
        return
452
    }
453

454
    // Add span attributes for observability
455
9x
    span.SetAttributes(
456
9x
        observability.AttributeUserID(userID),
457
9x
        attribute.String("date", dateStr),
458
9x
        attribute.Int("question_id", questionID),
459
9x
        attribute.String("timezone", timezone),
460
9x
        attribute.Int("user_answer_index", requestBody.UserAnswerIndex),
461
9x
    )
462
9x

463
9x
    // Submit the answer
464
9x
    response, err := h.dailyQuestionService.SubmitDailyQuestionAnswer(
465
9x
        ctx,
466
9x
        userID,
467
9x
        questionID,
468
9x
        date,
469
9x
        requestBody.UserAnswerIndex,
470
9x
    )
471
9x
    if err != nil {
472
        h.logger.Error(ctx, "Failed to submit daily question answer", err, map[string]interface{}{
473
            "user_id":           userID,
474
            "question_id":       questionID,
475
            "date":              dateStr,
476
            "timezone":          timezone,
477
            "user_answer_index": requestBody.UserAnswerIndex,
478
        })
479

480
        // Check for specific error types
481
        if contextutils.IsError(err, contextutils.ErrQuestionAlreadyAnswered) {
482
            HandleAppError(c, contextutils.ErrQuestionAlreadyAnswered)
483
            return
484
        }
485
        if contextutils.IsError(err, contextutils.ErrAssignmentNotFound) {
486
            HandleAppError(c, contextutils.ErrAssignmentNotFound)
487
            return
488
        }
489
        if contextutils.IsError(err, contextutils.ErrInvalidAnswerIndex) {
490
            HandleAppError(c, contextutils.ErrInvalidAnswerIndex)
491
            return
492
        }
493

494
        HandleAppError(c, contextutils.WrapError(err, "failed to submit answer"))
495
        return
496
    }
497

498
    // Add completion status to response
499
9x
    responseWithCompletion := gin.H{
500
9x
        "user_answer_index":    response.UserAnswerIndex,
501
9x
        "user_answer":          response.UserAnswer,
502
9x
        "is_correct":           response.IsCorrect,
503
9x
        "correct_answer_index": response.CorrectAnswerIndex,
504
9x
        "explanation":          response.Explanation,
505
9x
        "is_completed":         true,
506
9x
    }
507
9x

508
9x
    c.JSON(http.StatusOK, responseWithCompletion)
509
}
510

511
// GetQuestionHistory handles GET /v1/daily/questions/{questionId}/history
512
7x
func (h *DailyQuestionHandler) GetQuestionHistory(c *gin.Context) {
513
7x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_question_history")
514
7x
    defer observability.FinishSpan(span, nil)
515
7x

516
7x
    userID, exists := GetUserIDFromSession(c)
517
7x
    if !exists {
518
        HandleAppError(c, contextutils.ErrUnauthorized)
519
        return
520
    }
521

522
    // Parse question ID parameter
523
7x
    questionIDStr := c.Param("questionId")
524
7x
    if questionIDStr == "" {
525
        HandleAppError(c, contextutils.ErrMissingRequired)
526
        return
527
    }
528

529
7x
    questionID, err := strconv.Atoi(questionIDStr)
530
7x
    if err != nil {
531
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
532
1x
        return
533
1x
    }
534

535
    // Add span attributes for observability
536
6x
    span.SetAttributes(
537
6x
        observability.AttributeUserID(userID),
538
6x
        attribute.Int("question_id", questionID),
539
6x
    )
540
6x

541
6x
    // Get question history for the last 14 days
542
6x
    history, err := h.dailyQuestionService.GetQuestionHistory(ctx, userID, questionID, 14)
543
6x
    if err != nil {
544
        h.logger.Error(ctx, "Failed to get question history", err, map[string]interface{}{
545
            "user_id":     userID,
546
            "question_id": questionID,
547
        })
548
        HandleAppError(c, contextutils.WrapError(err, "failed to get question history"))
549
        return
550
    }
551

552
    // Determine user's timezone/location once, then filter out any future-dated assignments
553
6x
    user, _ := h.userService.GetUserByID(ctx, userID)
554
6x
    tz := "UTC"
555
6x
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
556
4x
        tz = user.Timezone.String
557
4x
    }
558
6x
    loc, locErr := time.LoadLocation(tz)
559
6x
    if locErr != nil {
560
        loc = time.UTC
561
    }
562
6x
    now := time.Now().In(loc)
563
6x
    today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
564
6x

565
6x
    // Format times in user's timezone using helper, skipping future dates
566
6x
    resp := make([]map[string]interface{}, 0, len(history))
567
6x
    for _, he := range history {
568
9x
        // Skip future assignments in user's local date
569
9x
        ad := he.AssignmentDate.In(loc)
570
9x
        adDate := time.Date(ad.Year(), ad.Month(), ad.Day(), 0, 0, 0, 0, loc)
571
9x
        if adDate.After(today) {
572
1x
            continue
573
        }
574

575
        // Return assignment_date as date-only string (YYYY-MM-DD) using the stored UTC
576
        // date to avoid timezone ambiguity for clients.
577
8x
        assignDateStr := he.AssignmentDate.UTC().Format("2006-01-02")
578
8x
        span.SetAttributes(attribute.String("assignment_date.formatted_with", "date_only"))
579
8x

580
8x
        entry := map[string]interface{}{
581
8x
            "assignment_date": assignDateStr,
582
8x
            "is_completed":    he.IsCompleted,
583
8x
            "is_correct":      nil,
584
8x
            "submitted_at":    nil,
585
8x
        }
586
8x
        if he.IsCorrect != nil {
587
1x
            entry["is_correct"] = *he.IsCorrect
588
1x
        }
589
8x
        if he.SubmittedAt != nil {
590
4x
            submittedStr, _, submittedErr := contextutils.FormatTimeInUserTimezone(ctx, userID, *he.SubmittedAt, time.RFC3339, h.userService.GetUserByID)
591
4x
            if submittedErr != nil || submittedStr == "" {
592
                h.logger.Error(ctx, "Failed to format submitted_at in user's timezone", submittedErr, map[string]interface{}{
593
                    "user_id":         userID,
594
                    "question_id":     questionID,
595
                    "submitted_at_db": he.SubmittedAt,
596
                })
597
                span.RecordError(submittedErr, trace.WithStackTrace(true))
598
                span.SetStatus(codes.Error, "failed to format submitted_at")
599
                HandleAppError(c, contextutils.WrapError(submittedErr, "failed to format submitted_at"))
600
                return
601
            }
602
4x
            span.SetAttributes(attribute.String("submitted_at.formatted_with", "user_timezone"))
603
4x
            entry["submitted_at"] = submittedStr
604
        }
605
8x
        resp = append(resp, entry)
606
    }
607

608
6x
    c.JSON(http.StatusOK, gin.H{"history": resp})
609
}
610


			
quizapp internal handlers worker_admin_handler.go
65.9%
Statements
27/41
1
package handlers
2

3
import (
4
    "fmt"
5
    "net/http"
6

7
    contextutils "quizapp/internal/utils"
8

9
    "github.com/gin-gonic/gin"
10
)
11

12
// StandardizeHTTPError creates consistent HTTP error responses with structured error information
13
5x
func StandardizeHTTPError(c *gin.Context, statusCode int, message, details string) {
14
5x
    // Map HTTP status code to appropriate error code
15
5x
    var errorCode contextutils.ErrorCode
16
5x
    var severity contextutils.SeverityLevel
17
5x

18
5x
    switch statusCode {
19
3x
    case http.StatusBadRequest:
20
3x
        errorCode = contextutils.ErrorCodeInvalidInput
21
3x
        severity = contextutils.SeverityWarn
22
    case http.StatusUnauthorized:
23
        errorCode = contextutils.ErrorCodeUnauthorized
24
        severity = contextutils.SeverityWarn
25
    case http.StatusForbidden:
26
        errorCode = contextutils.ErrorCodeForbidden
27
        severity = contextutils.SeverityWarn
28
1x
    case http.StatusNotFound:
29
1x
        errorCode = contextutils.ErrorCodeRecordNotFound
30
1x
        severity = contextutils.SeverityInfo
31
    case http.StatusConflict:
32
        errorCode = contextutils.ErrorCodeRecordExists
33
        severity = contextutils.SeverityInfo
34
    case http.StatusServiceUnavailable:
35
        errorCode = contextutils.ErrorCodeServiceUnavailable
36
        severity = contextutils.SeverityError
37
1x
    default:
38
1x
        errorCode = contextutils.ErrorCodeInternalError
39
1x
        severity = contextutils.SeverityError
40
    }
41

42
    // Create an AppError with appropriate code
43
5x
    appErr := contextutils.NewAppError(
44
5x
        errorCode,
45
5x
        severity,
46
5x
        message,
47
5x
        details,
48
5x
    )
49
5x

50
5x
    // Send response with the original status code
51
5x
    c.JSON(statusCode, appErr.ToJSON())
52
}
53

54
// StandardizeAppError sends a structured error response using AppError
55
99x
func StandardizeAppError(c *gin.Context, err *contextutils.AppError) {
56
99x
    // Map error codes to HTTP status codes
57
99x
    statusCode := mapErrorCodeToHTTPStatus(err.Code)
58
99x

59
99x
    // Convert error to JSON structure
60
99x
    errorJSON := err.ToJSON()
61
99x

62
99x
    // Add retryable information based on error type
63
99x
    errorJSON["retryable"] = contextutils.IsRetryable(err)
64
99x

65
99x
    c.JSON(statusCode, errorJSON)
66
99x
}
67

68
// HandleValidationError handles input validation errors consistently
69
9x
func HandleValidationError(c *gin.Context, field string, value interface{}, reason string) {
70
9x
    appErr := contextutils.NewAppError(
71
9x
        contextutils.ErrorCodeInvalidInput,
72
9x
        contextutils.SeverityWarn,
73
9x
        fmt.Sprintf("Invalid %s", field),
74
9x
        fmt.Sprintf("Value '%v' is invalid: %s", value, reason),
75
9x
    )
76
9x

77
9x
    StandardizeAppError(c, appErr)
78
9x
}
79

80
// HandleAppError handles any AppError and sends appropriate HTTP response
81
90x
func HandleAppError(c *gin.Context, err error) {
82
90x
    if appErr, ok := err.(*contextutils.AppError); ok {
83
90x
        StandardizeAppError(c, appErr)
84
90x
    } else {
85
        // Fallback for non-AppError types
86
        StandardizeHTTPError(c, http.StatusInternalServerError, "Internal server error", err.Error())
87
    }
88
}
89

90
// mapErrorCodeToHTTPStatus maps AppError codes to appropriate HTTP status codes
91
99x
func mapErrorCodeToHTTPStatus(code contextutils.ErrorCode) int {
92
99x
    switch code {
93
    // 4xx Client Errors
94
    case contextutils.ErrorCodeInvalidInput, contextutils.ErrorCodeMissingRequired,
95
        contextutils.ErrorCodeInvalidFormat, contextutils.ErrorCodeValidationFailed,
96
66x
        contextutils.ErrorCodeOAuthStateMismatch:
97
66x
        return http.StatusBadRequest
98

99
1x
    case contextutils.ErrorCodeUnauthorized:
100
1x
        return http.StatusUnauthorized
101

102
2x
    case contextutils.ErrorCodeForbidden:
103
2x
        return http.StatusForbidden
104

105
    case contextutils.ErrorCodeRecordNotFound, contextutils.ErrorCodeQuestionNotFound,
106
7x
        contextutils.ErrorCodeAssignmentNotFound:
107
7x
        return http.StatusNotFound
108

109
6x
    case contextutils.ErrorCodeRecordExists:
110
6x
        return http.StatusConflict
111

112
10x
    case contextutils.ErrorCodeSessionExpired, contextutils.ErrorCodeInvalidCredentials:
113
10x
        return http.StatusUnauthorized
114

115
    case contextutils.ErrorCodeRateLimit:
116
        return http.StatusTooManyRequests
117

118
    // 5xx Server Errors
119
2x
    case contextutils.ErrorCodeInternalError:
120
2x
        return http.StatusInternalServerError
121

122
    case contextutils.ErrorCodeServiceUnavailable, contextutils.ErrorCodeDatabaseConnection,
123
        contextutils.ErrorCodeAIProviderUnavailable:
124
        return http.StatusServiceUnavailable
125

126
    case contextutils.ErrorCodeTimeout:
127
        return http.StatusRequestTimeout
128

129
    case contextutils.ErrorCodeDatabaseQuery, contextutils.ErrorCodeDatabaseTransaction,
130
        contextutils.ErrorCodeForeignKeyViolation, contextutils.ErrorCodeTimestampMissingTimezone,
131
        contextutils.ErrorCodeAIRequestFailed, contextutils.ErrorCodeAIResponseInvalid,
132
        contextutils.ErrorCodeAIConfigInvalid, contextutils.ErrorCodeOAuthProviderError:
133
        return http.StatusInternalServerError
134

135
    // Default to internal server error for unknown codes
136
    default:
137
        return http.StatusInternalServerError
138
    }
139
}
140


			
quizapp internal handlers worker_admin_handler.go
100.0%
Statements
20/20
1
package handlers
2

3
import (
4
    "net/http"
5
    "strconv"
6
    "strings"
7

8
    "github.com/gin-gonic/gin"
9
)
10

11
// ParsePagination parses standard pagination query params from the request.
12
// It enforces bounds and applies defaults when values are missing or invalid.
13
10x
func ParsePagination(c *gin.Context, defaultPage, defaultSize, maxSize int) (int, int) {
14
10x
    pageStr := c.DefaultQuery("page", strconv.Itoa(defaultPage))
15
10x
    sizeStr := c.DefaultQuery("page_size", strconv.Itoa(defaultSize))
16
10x

17
10x
    page, err := strconv.Atoi(pageStr)
18
10x
    if err != nil || page < 1 {
19
1x
        page = defaultPage
20
1x
    }
21

22
10x
    size, err := strconv.Atoi(sizeStr)
23
10x
    if err != nil || size < 1 {
24
1x
        size = defaultSize
25
1x
    }
26
10x
    if size > maxSize {
27
1x
        size = maxSize
28
1x
    }
29

30
10x
    return page, size
31
}
32

33
// ParseFilters returns a map of non-empty trimmed query params for the given keys.
34
4x
func ParseFilters(c *gin.Context, keys ...string) map[string]string {
35
4x
    filters := make(map[string]string, len(keys))
36
4x
    for _, key := range keys {
37
19x
        if val := strings.TrimSpace(c.Query(key)); val != "" {
38
4x
            filters[key] = val
39
4x
        }
40
    }
41
4x
    return filters
42
}
43

44
// WritePaginated standardizes paginated responses with a flexible items key, pagination block, and optional extras.
45
// It preserves existing API response shapes by allowing the caller to specify the items key.
46
1x
func WritePaginated(c *gin.Context, itemsKey string, items, pagination any, extra gin.H) {
47
1x
    response := gin.H{
48
1x
        itemsKey:     items,
49
1x
        "pagination": pagination,
50
1x
    }
51
1x
    for k, v := range extra {
52
1x
        response[k] = v
53
1x
    }
54
1x
    c.JSON(http.StatusOK, response)
55
}
56


			
quizapp internal handlers worker_admin_handler.go
65.0%
Statements
290/446
1
package handlers
2

3
import (
4
    "context"
5
    "encoding/json"
6
    "fmt"
7
    "io"
8
    "math/rand"
9
    "net/http"
10
    "strconv"
11
    "strings"
12
    "time"
13

14
    "quizapp/internal/api"
15
    "quizapp/internal/models"
16
    "quizapp/internal/observability"
17
    "quizapp/internal/services"
18
    contextutils "quizapp/internal/utils"
19

20
    "quizapp/internal/config"
21

22
    "github.com/gin-gonic/gin"
23
    "go.opentelemetry.io/otel/attribute"
24
)
25

26
// QuizHandler handles quiz-related HTTP requests including questions and answers
27
type QuizHandler struct {
28
    userService     services.UserServiceInterface
29
    questionService services.QuestionServiceInterface
30
    aiService       services.AIServiceInterface
31
    learningService services.LearningServiceInterface
32
    workerService   services.WorkerServiceInterface
33
    hintService     services.GenerationHintServiceInterface
34
    cfg             *config.Config
35
    logger          *observability.Logger
36
}
37

38
// NewQuizHandler creates a new QuizHandler
39
func NewQuizHandler(
40
    userService services.UserServiceInterface,
41
    questionService services.QuestionServiceInterface,
42
    aiService services.AIServiceInterface,
43
    learningService services.LearningServiceInterface,
44
    workerService services.WorkerServiceInterface,
45
    hintService services.GenerationHintServiceInterface,
46
    config *config.Config,
47
    logger *observability.Logger,
48
11x
) *QuizHandler {
49
11x
    return &QuizHandler{
50
11x
        userService:     userService,
51
11x
        questionService: questionService,
52
11x
        aiService:       aiService,
53
11x
        learningService: learningService,
54
11x
        workerService:   workerService,
55
11x
        hintService:     hintService,
56
11x
        cfg:             config,
57
11x
        logger:          logger,
58
11x
    }
59
11x
}
60

61
// Deprecated: use GetUserIDFromSession in session.go
62
5x
func (h *QuizHandler) getUserIDFromSession(c *gin.Context) (int, bool) {
63
5x
    return GetUserIDFromSession(c)
64
5x
}
65

66
// GetQuestion handles requests for quiz questions
67
8x
func (h *QuizHandler) GetQuestion(c *gin.Context) {
68
8x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_question")
69
8x
    defer observability.FinishSpan(span, nil)
70
8x

71
8x
    userID, exists := GetUserIDFromSession(c)
72
8x
    if !exists {
73
        HandleAppError(c, contextutils.ErrUnauthorized)
74
        return
75
    }
76

77
    // Add span attributes for observability
78
8x
    span.SetAttributes(observability.AttributeUserID(userID))
79
8x

80
8x
    // Check if a specific question ID is requested
81
8x
    questionIDStr := c.Param("id")
82
8x
    if questionIDStr != "" {
83
5x
        span.SetAttributes(attribute.String("question.id", questionIDStr))
84
5x
        h.getSpecificQuestion(c, userID, questionIDStr)
85
5x
        return
86
5x
    }
87

88
3x
    h.getNextQuestion(c, userID)
89
}
90

91
// getSpecificQuestion improves error handling with centralized utilities
92
5x
func (h *QuizHandler) getSpecificQuestion(c *gin.Context, userID int, questionIDStr string) {
93
5x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_specific_question",
94
5x
        observability.AttributeUserID(userID),
95
5x
        attribute.String("question.id_str", questionIDStr),
96
5x
    )
97
5x
    defer observability.FinishSpan(span, nil)
98
5x

99
5x
    questionID, err := strconv.Atoi(questionIDStr)
100
5x
    if err != nil {
101
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
102
1x
            contextutils.ErrorCodeInvalidInput,
103
1x
            contextutils.SeverityWarn,
104
1x
            "Invalid question ID format",
105
1x
            "Question ID must be a valid integer",
106
1x
            err,
107
1x
        ))
108
1x
        return
109
1x
    }
110

111
4x
    questionWithStats, err := h.questionService.GetQuestionWithStats(ctx, questionID)
112
4x
    if err != nil {
113
        h.logger.Error(ctx, "Failed to get question with stats", err, map[string]interface{}{
114
            "question_id": questionID,
115
            "user_id":     userID,
116
        })
117
        HandleAppError(c, contextutils.WrapError(err, "failed to get question with stats"))
118
        return
119
    }
120

121
    // Convert and hide sensitive information
122
4x
    apiQuestion := convertQuestionToAPI(questionWithStats.Question)
123
4x
    apiQuestion.Explanation = nil // Hide explanation
124
4x

125
4x
    // Add response statistics to the API question
126
4x
    apiQuestion.CorrectCount = &questionWithStats.CorrectCount
127
4x
    apiQuestion.IncorrectCount = &questionWithStats.IncorrectCount
128
4x
    apiQuestion.TotalResponses = &questionWithStats.TotalResponses
129
4x

130
4x
    // Get user-specific confidence level if available
131
4x
    confidenceLevel, err := h.learningService.GetUserQuestionConfidenceLevel(ctx, userID, questionID)
132
4x
    if err != nil {
133
        h.logger.Warn(ctx, "Failed to get user confidence level", map[string]interface{}{
134
            "error":       err.Error(),
135
            "question_id": questionID,
136
            "user_id":     userID,
137
        })
138
        // Don't fail the request, just continue without confidence level
139
    } else if confidenceLevel != nil {
140
        apiQuestion.ConfidenceLevel = confidenceLevel
141
1x
    }
142

143
4x
    c.JSON(http.StatusOK, apiQuestion)
144
}
145

146
// getNextQuestion improves error handling with centralized utilities
147
3x
func (h *QuizHandler) getNextQuestion(c *gin.Context, userID int) {
148
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_next_question",
149
3x
        observability.AttributeUserID(userID),
150
3x
    )
151
3x
    defer observability.FinishSpan(span, nil)
152
3x

153
3x
    user, err := h.userService.GetUserByID(ctx, userID)
154
3x
    if err != nil {
155
        h.logger.Error(ctx, "Failed to get user by ID", err, map[string]interface{}{
156
            "user_id": userID,
157
        })
158
        HandleAppError(c, contextutils.WrapError(err, "failed to get user by ID"))
159
        return
160
    }
161
3x
    if user == nil {
162
        span.SetAttributes(attribute.String("error.type", "user_nil"))
163
        HandleAppError(c, contextutils.ErrRecordNotFound)
164
        return
165
    }
166

167
    // Check if user has required preferences set
168
3x
    if !user.PreferredLanguage.Valid || user.PreferredLanguage.String == "" {
169
        span.SetAttributes(attribute.String("error.type", "missing_language_preference"))
170
        HandleAppError(c, contextutils.NewAppErrorWithCause(
171
            contextutils.ErrorCodeMissingRequired,
172
            contextutils.SeverityWarn,
173
            "Language preference not set",
174
            "Please set your preferred language in settings",
175
            nil,
176
        ))
177
        return
178
    }
179

180
3x
    if !user.CurrentLevel.Valid || user.CurrentLevel.String == "" {
181
        span.SetAttributes(attribute.String("error.type", "missing_level_preference"))
182
        HandleAppError(c, contextutils.NewAppErrorWithCause(
183
            contextutils.ErrorCodeMissingRequired,
184
            contextutils.SeverityWarn,
185
            "Level preference not set",
186
            "Please set your current level in settings",
187
            nil,
188
        ))
189
        return
190
    }
191

192
3x
    language := c.DefaultQuery("language", user.PreferredLanguage.String)
193
3x
    level := c.DefaultQuery("level", user.CurrentLevel.String)
194
3x

195
3x
    // Handle question type selection based on query parameters
196
3x
    var qType models.QuestionType
197
3x
    requestedTypes := c.Query("type")
198
3x
    strictTypeRequested := false
199
3x

200
3x
    if requestedTypes != "" {
201
1x
        strictTypeRequested = true
202
1x
        types := strings.Split(requestedTypes, ",")
203
1x
        // Use the first valid type from the list
204
1x
        for _, t := range types {
205
1x
            if t = strings.TrimSpace(t); t != "" {
206
1x
                qType = models.QuestionType(t)
207
1x
                break
208
            }
209
        }
210
2x
    } else {
211
2x
        // Check if we need to exclude certain types (comma-separated list)
212
2x
        excludeTypes := c.Query("exclude_type")
213
2x
        if excludeTypes != "" {
214
            excludeList := strings.Split(excludeTypes, ",")
215
            var excludeSet []models.QuestionType
216
            for _, t := range excludeList {
217
                if t = strings.TrimSpace(t); t != "" {
218
                    excludeSet = append(excludeSet, models.QuestionType(t))
219
                }
220
            }
221
            qType = h.selectRandomQuestionTypeExcluding(excludeSet...)
222
2x
        } else {
223
2x
            // Default random selection
224
2x
            qType = h.selectRandomQuestionType()
225
2x
        }
226
    }
227

228
    // Add span attributes for observability
229
3x
    span.SetAttributes(
230
3x
        attribute.String("language", language),
231
3x
        attribute.String("level", level),
232
3x
        attribute.String("question.type", string(qType)),
233
3x
        attribute.Bool("strict.type.requested", strictTypeRequested),
234
3x
    )
235
3x

236
3x
    // Get next question with fallback logic
237
3x
    questionWithStats, err := h.questionService.GetNextQuestion(ctx, userID, language, level, qType)
238
3x
    if err != nil {
239
        h.logger.Error(ctx, "Failed to get next question", err, map[string]interface{}{
240
            "user_id":       userID,
241
            "language":      language,
242
            "level":         level,
243
            "question_type": string(qType),
244
        })
245

246
        // Fallback: try without question type if strict type was requested
247
        if strictTypeRequested {
248
            h.logger.Info(ctx, "Attempting fallback without question type", map[string]interface{}{
249
                "user_id":  userID,
250
                "language": language,
251
                "level":    level,
252
            })
253
            questionWithStats, err = h.questionService.GetNextQuestion(ctx, userID, language, level, "")
254
            if err != nil {
255
                h.logger.Error(ctx, "Fallback also failed", err, map[string]interface{}{
256
                    "user_id":  userID,
257
                    "language": language,
258
                    "level":    level,
259
                })
260
                HandleAppError(c, contextutils.ErrNoQuestionsAvailable)
261
                return
262
            }
263
        } else {
264
            HandleAppError(c, contextutils.ErrNoQuestionsAvailable)
265
            return
266
        }
267
    }
268

269
    // Check if we got a valid question
270
3x
    if questionWithStats == nil || questionWithStats.Question == nil {
271
3x
        h.logger.Error(ctx, "GetNextQuestion returned nil question", nil, map[string]interface{}{
272
3x
            "user_id":       userID,
273
3x
            "language":      language,
274
3x
            "level":         level,
275
3x
            "question_type": string(qType),
276
3x
        })
277
3x
        // If the user strictly requested a type, record a generation hint with short TTL
278
3x
        if strictTypeRequested && h.hintService != nil && qType != "" {
279
1x
            // Best-effort; do not fail the request if hint upsert fails
280
1x
            _ = h.hintService.UpsertHint(ctx, userID, language, level, qType, 10*time.Minute)
281
1x
        }
282
3x
        c.JSON(http.StatusAccepted, api.GeneratingResponse{
283
3x
            Status:  stringPtr("generating"),
284
3x
            Message: stringPtr("No questions available. Prioritizing your requested question type. Please try again shortly."),
285
3x
        })
286
3x
        return
287
    }
288

289
    // Convert to API format and hide sensitive information
290
    apiQuestion := convertQuestionToAPI(questionWithStats.Question)
291
    apiQuestion.Explanation = nil // Hide explanation
292

293
    // Add response statistics to the API question
294
    apiQuestion.CorrectCount = &questionWithStats.CorrectCount
295
    apiQuestion.IncorrectCount = &questionWithStats.IncorrectCount
296
    apiQuestion.TotalResponses = &questionWithStats.TotalResponses
297

298
    // Add confidence level if available
299
    if questionWithStats.ConfidenceLevel != nil {
300
        apiQuestion.ConfidenceLevel = questionWithStats.ConfidenceLevel
301
    }
302

303
    c.JSON(http.StatusOK, apiQuestion)
304
}
305

306
// SubmitAnswer improves error handling with centralized utilities
307
15x
func (h *QuizHandler) SubmitAnswer(c *gin.Context) {
308
15x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "submit_answer")
309
15x
    defer observability.FinishSpan(span, nil)
310
15x

311
15x
    userID, exists := GetUserIDFromSession(c)
312
15x
    if !exists {
313
        HandleAppError(c, contextutils.ErrUnauthorized)
314
        return
315
    }
316

317
15x
    var req api.AnswerRequest
318
15x
    if err := c.ShouldBindJSON(&req); err != nil {
319
1x
        h.logger.Error(ctx, "Invalid answer request format", err, map[string]interface{}{
320
1x
            "user_id": userID,
321
1x
        })
322
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
323
1x
            contextutils.ErrorCodeInvalidInput,
324
1x
            contextutils.SeverityWarn,
325
1x
            "Invalid request format",
326
1x
            "",
327
1x
            err,
328
1x
        ))
329
1x
        return
330
1x
    }
331

332
    // Get the question
333
14x
    question, err := h.questionService.GetQuestionByID(ctx, int(req.QuestionId))
334
14x
    if err != nil {
335
        h.logger.Error(ctx, "Failed to get question by ID", err, map[string]interface{}{
336
            "question_id": req.QuestionId,
337
            "user_id":     userID,
338
        })
339
        HandleAppError(c, contextutils.ErrQuestionNotFound)
340
        return
341
    }
342

343
    // Check if answer is correct
344
14x
    isCorrect := int(req.UserAnswerIndex) == question.CorrectAnswer
345
14x

346
14x
    // Record user response
347
14x
    responseTimeMs := 0
348
14x
    if req.ResponseTimeMs != nil {
349
        responseTimeMs = int(*req.ResponseTimeMs)
350
    }
351

352
    // Use priority-aware recording to ensure priority scores are updated
353
    // Store the user's answer index for future reference
354
14x
    if err := h.learningService.RecordAnswerWithPriority(ctx, userID, int(req.QuestionId), int(req.UserAnswerIndex), isCorrect, responseTimeMs); err != nil {
355
        h.logger.Error(ctx, "Failed to record user response", err, map[string]interface{}{
356
            "user_id":     userID,
357
            "question_id": req.QuestionId,
358
        })
359
        HandleAppError(c, contextutils.WrapError(err, "failed to record response"))
360
        return
361
    }
362

363
    // Prepare response
364
    // Get the user's answer text from the question options
365
14x
    userAnswerText := ""
366
14x
    if optionsRaw, ok := question.Content["options"]; ok {
367
14x
        if options, ok := optionsRaw.([]interface{}); ok {
368
14x
            if int(req.UserAnswerIndex) >= 0 && int(req.UserAnswerIndex) < len(options) {
369
14x
                if optStr, ok := options[int(req.UserAnswerIndex)].(string); ok {
370
14x
                    userAnswerText = optStr
371
14x
                }
372
            }
373
        }
374
    }
375

376
14x
    answerResponse := &api.AnswerResponse{
377
14x
        IsCorrect:          &isCorrect,
378
14x
        UserAnswer:         &userAnswerText,
379
14x
        UserAnswerIndex:    &req.UserAnswerIndex,
380
14x
        Explanation:        &question.Explanation,
381
14x
        CorrectAnswerIndex: &question.CorrectAnswer,
382
14x
    }
383
14x

384
14x
    c.JSON(http.StatusOK, answerResponse)
385
14x

386
14x
    // Add span attributes for observability
387
14x
    span.SetAttributes(
388
14x
        attribute.Int("user.id", userID),
389
14x
        attribute.Int("question.id", int(req.QuestionId)),
390
14x
        attribute.Bool("answer.is_correct", isCorrect),
391
14x
        attribute.Int("response.time_ms", responseTimeMs),
392
14x
    )
393
}
394

395
// GetProgress improves error handling with centralized utilities
396
18x
func (h *QuizHandler) GetProgress(c *gin.Context) {
397
18x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_progress")
398
18x
    defer observability.FinishSpan(span, nil)
399
18x

400
18x
    userID, exists := GetUserIDFromSession(c)
401
18x
    if !exists {
402
        HandleAppError(c, contextutils.ErrUnauthorized)
403
        return
404
    }
405

406
18x
    span.SetAttributes(observability.AttributeUserID(userID))
407
18x

408
18x
    progress, err := h.learningService.GetUserProgress(ctx, userID)
409
18x
    if err != nil {
410
        h.logger.Error(ctx, "Failed to get user progress", err, map[string]interface{}{
411
            "user_id": userID,
412
        })
413
        HandleAppError(c, contextutils.WrapError(err, "failed to get progress"))
414
        return
415
    }
416

417
    // Get worker status information
418
18x
    workerStatus, err := h.getWorkerStatusForUser(ctx, userID)
419
18x
    if err != nil {
420
        h.logger.Warn(ctx, "Failed to get worker status for user", map[string]interface{}{
421
            "user_id": userID,
422
            "error":   err.Error(),
423
        })
424
        // Don't fail the entire request, just log the warning
425
    }
426

427
    // Get learning preferences
428
18x
    learningPrefs, err := h.learningService.GetUserLearningPreferences(ctx, userID)
429
18x
    if err != nil {
430
        h.logger.Warn(ctx, "Failed to get learning preferences for user", map[string]interface{}{
431
            "user_id": userID,
432
            "error":   err.Error(),
433
        })
434
        // Don't fail the entire request, just log the warning
435
    }
436

437
    // Get priority insights
438
18x
    priorityInsights, err := h.getPriorityInsightsForUser(ctx, userID)
439
18x
    if err != nil {
440
        h.logger.Warn(ctx, "Failed to get priority insights for user", map[string]interface{}{
441
            "user_id": userID,
442
            "error":   err.Error(),
443
        })
444
        // Don't fail the entire request, just log the warning
445
    }
446

447
    // Get generation focus information
448
18x
    generationFocus, err := h.getGenerationFocusForUser(ctx, userID)
449
18x
    if err != nil {
450
        h.logger.Warn(ctx, "Failed to get generation focus for user", map[string]interface{}{
451
            "user_id": userID,
452
            "error":   err.Error(),
453
        })
454
        // Don't fail the entire request, just log the warning
455
    }
456

457
    // Get high priority topics
458
18x
    highPriorityTopics, err := h.getHighPriorityTopicsForUser(ctx, userID)
459
18x
    if err != nil {
460
        h.logger.Warn(ctx, "Failed to get high priority topics for user", map[string]interface{}{
461
            "user_id": userID,
462
            "error":   err.Error(),
463
        })
464
        // Don't fail the entire request, just log the warning
465
    }
466

467
    // Get gap analysis
468
18x
    gapAnalysis, err := h.getGapAnalysisForUser(ctx, userID)
469
18x
    if err != nil {
470
        h.logger.Warn(ctx, "Failed to get gap analysis for user", map[string]interface{}{
471
            "user_id": userID,
472
            "error":   err.Error(),
473
        })
474
        // Don't fail the entire request, just log the warning
475
    }
476

477
    // Get priority distribution
478
18x
    priorityDistribution, err := h.getPriorityDistributionForUser(ctx, userID)
479
18x
    if err != nil {
480
        h.logger.Warn(ctx, "Failed to get priority distribution for user", map[string]interface{}{
481
            "user_id": userID,
482
            "error":   err.Error(),
483
        })
484
        // Don't fail the entire request, just log the warning
485
    }
486

487
    // Convert models.UserProgress to api.UserProgress
488
18x
    apiProgress := convertUserProgressToAPI(ctx, progress, userID, h.userService.GetUserByID)
489
18x

490
18x
    // Add worker-related information
491
18x
    if workerStatus != nil {
492
18x
        apiProgress.WorkerStatus = workerStatus
493
18x
    }
494
18x
    if learningPrefs != nil {
495
18x
        apiProgress.LearningPreferences = convertLearningPreferencesToAPI(learningPrefs)
496
18x
    }
497
18x
    if priorityInsights != nil {
498
18x
        apiProgress.PriorityInsights = priorityInsights
499
18x
    }
500
18x
    if generationFocus != nil {
501
18x
        apiProgress.GenerationFocus = generationFocus
502
18x
    }
503
18x
    if highPriorityTopics != nil {
504
18x
        apiProgress.HighPriorityTopics = &highPriorityTopics
505
18x
    }
506
18x
    if gapAnalysis != nil {
507
18x
        apiProgress.GapAnalysis = &gapAnalysis
508
18x
    }
509
18x
    if priorityDistribution != nil {
510
18x
        apiProgress.PriorityDistribution = &priorityDistribution
511
18x
    }
512

513
18x
    c.JSON(http.StatusOK, apiProgress)
514
}
515

516
// ReportQuestion improves error handling with centralized utilities
517
7x
func (h *QuizHandler) ReportQuestion(c *gin.Context) {
518
7x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "report_question")
519
7x
    defer observability.FinishSpan(span, nil)
520
7x

521
7x
    userID, exists := GetUserIDFromSession(c)
522
7x
    if !exists {
523
        HandleAppError(c, contextutils.ErrUnauthorized)
524
        return
525
    }
526

527
7x
    questionIDStr := c.Param("id")
528
7x
    questionID, err := strconv.Atoi(questionIDStr)
529
7x
    if err != nil {
530
        HandleValidationError(c, "question_id", questionIDStr, "must be a valid integer")
531
        return
532
    }
533

534
    // Parse request body for report reason
535
7x
    var req struct {
536
7x
        ReportReason *string `json:"report_reason"`
537
7x
    }
538
7x

539
7x
    // Bind JSON if present (optional)
540
7x
    if err := c.ShouldBindJSON(&req); err != nil {
541
4x
        // Ignore binding errors for optional request body
542
4x
        req.ReportReason = nil
543
4x
    }
544

545
    // Get report reason, default to empty string if not provided
546
7x
    reportReason := ""
547
7x
    if req.ReportReason != nil {
548
3x
        reportReason = *req.ReportReason
549
3x
    }
550

551
7x
    span.SetAttributes(
552
7x
        observability.AttributeUserID(userID),
553
7x
        observability.AttributeQuestionID(questionID),
554
7x
    )
555
7x

556
7x
    err = h.questionService.ReportQuestion(ctx, questionID, userID, reportReason)
557
7x
    if err != nil {
558
1x
        h.logger.Error(ctx, "Failed to report question", err, map[string]interface{}{
559
1x
            "question_id": questionID,
560
1x
            "user_id":     userID,
561
1x
        })
562
1x
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
563
1x
            HandleAppError(c, contextutils.ErrQuestionNotFound)
564
1x
            return
565
1x
        }
566
        HandleAppError(c, contextutils.WrapError(err, "failed to report question"))
567
        return
568
    }
569

570
6x
    c.JSON(http.StatusOK, api.SuccessResponse{Success: true, Message: stringPtr("Question reported successfully")})
571
}
572

573
// MarkQuestionAsKnown improves error handling with centralized utilities
574
6x
func (h *QuizHandler) MarkQuestionAsKnown(c *gin.Context) {
575
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "mark_question_as_known")
576
6x
    defer observability.FinishSpan(span, nil)
577
6x

578
6x
    userID, exists := GetUserIDFromSession(c)
579
6x
    if !exists {
580
        HandleAppError(c, contextutils.ErrUnauthorized)
581
        return
582
    }
583

584
6x
    questionIDStr := c.Param("id")
585
6x
    questionID, err := strconv.Atoi(questionIDStr)
586
6x
    if err != nil {
587
1x
        HandleValidationError(c, "question_id", questionIDStr, "must be a valid integer")
588
1x
        return
589
1x
    }
590

591
    // Optional: Parse confidence level from request body
592
5x
    var req struct {
593
5x
        ConfidenceLevel *int `json:"confidence_level"`
594
5x
    }
595
5x

596
5x
    // Bind JSON if present (optional)
597
5x
    if err := c.ShouldBindJSON(&req); err != nil {
598
3x
        // Ignore binding errors for optional request body
599
3x
        req.ConfidenceLevel = nil
600
3x
    }
601

602
5x
    span.SetAttributes(
603
5x
        observability.AttributeUserID(userID),
604
5x
        observability.AttributeQuestionID(questionID),
605
5x
    )
606
5x

607
5x
    // Mark question as known with confidence level
608
5x
    err = h.learningService.MarkQuestionAsKnown(ctx, userID, questionID, req.ConfidenceLevel)
609
5x
    if err != nil {
610
1x
        h.logger.Error(ctx, "Failed to mark question as known for user", err, map[string]interface{}{
611
1x
            "question_id": questionID,
612
1x
            "user_id":     userID,
613
1x
        })
614
1x
        if contextutils.IsError(err, contextutils.ErrQuestionNotFound) {
615
1x
            HandleAppError(c, contextutils.ErrQuestionNotFound)
616
1x
            return
617
1x
        }
618
        HandleAppError(c, contextutils.WrapError(err, "failed to mark question as known"))
619
        return
620
    }
621

622
4x
    c.JSON(http.StatusOK, api.SuccessResponse{Success: true, Message: stringPtr("Question marked as known successfully")})
623
}
624

625
// ChatStream handles requests for AI-powered streaming chat about a question
626
3x
func (h *QuizHandler) ChatStream(c *gin.Context) {
627
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "chat_stream")
628
3x
    defer observability.FinishSpan(span, nil)
629
3x

630
3x
    userID, exists := h.getUserIDFromSession(c)
631
3x
    if !exists {
632
        HandleAppError(c, contextutils.ErrUnauthorized)
633
        return
634
    }
635

636
3x
    var req api.QuizChatRequest
637
3x
    if err := c.ShouldBindJSON(&req); err != nil {
638
        HandleAppError(c, contextutils.NewAppErrorWithCause(
639
            contextutils.ErrorCodeInvalidInput,
640
            contextutils.SeverityWarn,
641
            "Invalid request format",
642
            "",
643
            err,
644
        ))
645
        return
646
    }
647

648
3x
    user, err := h.userService.GetUserByID(ctx, userID)
649
3x
    if err != nil || user == nil {
650
        HandleAppError(c, contextutils.ErrRecordNotFound)
651
        return
652
    }
653

654
3x
    span.SetAttributes(
655
3x
        observability.AttributeUserID(userID),
656
3x
        attribute.String("ai.provider", user.AIProvider.String),
657
3x
        attribute.String("ai.model", user.AIModel.String),
658
3x
    )
659
3x

660
3x
    // Prepare the request for the AI service
661
3x
    aiReq := &models.AIChatRequest{
662
3x
        Language:     string(*req.Question.Language),
663
3x
        Level:        string(*req.Question.Level),
664
3x
        QuestionType: models.QuestionType(*req.Question.Type),
665
3x
        UserMessage:  req.UserMessage,
666
3x
    }
667
3x

668
3x
    if req.Question.Content != nil {
669
3x
        aiReq.Question = req.Question.Content.Question
670
3x
        aiReq.Options = req.Question.Content.Options
671
3x
        if req.Question.Content.Passage != nil {
672
1x
            aiReq.Passage = *req.Question.Content.Passage
673
1x
        }
674
        // For vocabulary questions, use the sentence field as the passage
675
3x
        if req.Question.Content.Sentence != nil && req.Question.Type != nil && *req.Question.Type == "vocabulary" {
676
1x
            aiReq.Passage = *req.Question.Content.Sentence
677
1x
        }
678
    }
679

680
3x
    if req.AnswerContext != nil {
681
        if req.AnswerContext.UserAnswer != nil {
682
            aiReq.UserAnswer = *req.AnswerContext.UserAnswer
683
        }
684
        if req.AnswerContext.IsCorrect != nil {
685
            aiReq.IsCorrect = req.AnswerContext.IsCorrect
686
        }
687
    }
688

689
    // Include conversation history if provided
690
3x
    if req.ConversationHistory != nil {
691
        aiReq.ConversationHistory = make([]models.ChatMessage, len(*req.ConversationHistory))
692
        for i, msg := range *req.ConversationHistory {
693
            aiReq.ConversationHistory[i] = models.ChatMessage{
694
                Role:    msg.Role,
695
                Content: msg.Content,
696
            }
697
        }
698
    }
699

700
    // Create user AI configuration
701
3x
    userConfig := &services.UserAIConfig{
702
3x
        Provider: "", // will be set from user settings
703
3x
        Model:    "", // use service default
704
3x
        APIKey:   "",
705
3x
        Username: user.Username,
706
3x
    }
707
3x
    if user.AIProvider.Valid && user.AIProvider.String != "" {
708
        userConfig.Provider = user.AIProvider.String
709
    }
710
3x
    if user.AIModel.Valid && user.AIModel.String != "" {
711
        userConfig.Model = user.AIModel.String
712
    }
713
    // Use the new per-provider API key system instead of the old user.AIAPIKey field
714
3x
    if userConfig.Provider != "" {
715
        savedKey, err := h.userService.GetUserAPIKey(c.Request.Context(), userID, userConfig.Provider)
716
        if err == nil && savedKey != "" {
717
            userConfig.APIKey = savedKey
718
        }
719
    }
720

721
    // Set up Server-Sent Events headers
722
3x
    c.Header("Content-Type", "text/event-stream")
723
3x
    c.Header("Cache-Control", "no-cache")
724
3x
    c.Header("Connection", "keep-alive")
725
3x
    c.Header("Access-Control-Allow-Origin", "*")
726
3x
    c.Header("Access-Control-Allow-Headers", "Cache-Control")
727
3x

728
3x
    // Create a channel for streaming chunks
729
3x
    chunks := make(chan string, 10)
730
3x

731
3x
    // Use the request context to detect client disconnect
732
3x
    reqCtx := c.Request.Context()
733
3x

734
3x
    // Create a timeout context, but also watch for client disconnect
735
3x
    timeoutCtx, cancel := context.WithTimeout(reqCtx, config.QuizStreamTimeout)
736
3x
    defer cancel()
737
3x

738
3x
    // Combine both contexts - cancel if either times out or client disconnects
739
3x
    ctx, combinedCancel := context.WithCancel(timeoutCtx)
740
3x
    defer combinedCancel()
741
3x

742
3x
    // Watch for client disconnect
743
3x
    go func() {
744
3x
        defer func() {
745
3x
            if r := recover(); r != nil {
746
                h.logger.Error(ctx, "Panic in client disconnect watcher", nil, map[string]interface{}{
747
                    "panic": r,
748
                })
749
            }
750
        }()
751
3x
        select {
752
        case <-reqCtx.Done():
753
            combinedCancel() // Cancel if client disconnects
754
3x
        case <-ctx.Done():
755
            // Context already cancelled
756
        }
757
    }()
758

759
    // Start the AI streaming in a goroutine
760
3x
    go func() {
761
3x
        defer func() {
762
3x
            if r := recover(); r != nil {
763
                h.logger.Error(ctx, "Panic in AI streaming goroutine", nil, map[string]interface{}{
764
                    "panic": r,
765
                })
766
            }
767
3x
            close(chunks) // Close the channel when the goroutine completes
768
        }()
769
3x
        if err := h.aiService.GenerateChatResponseStream(ctx, userConfig, aiReq, chunks); err != nil {
770
3x
            h.logger.Error(ctx, "AI chat streaming failed for user", err, map[string]interface{}{
771
3x
                "user_id": userID,
772
3x
            })
773
3x
            // Only send error if context is not cancelled (avoid sending to closed channel)
774
3x
            if ctx.Err() == nil {
775
3x
                select {
776
3x
                case chunks <- fmt.Sprintf("ERROR: %v", err):
777
                default:
778
                    // Channel full, skip sending error
779
                }
780
            }
781
        }
782
    }()
783

784
    // Stream the response chunks
785
3x
    c.Stream(func(w io.Writer) bool {
786
3x
        select {
787
3x
        case chunk, ok := <-chunks:
788
3x
            if !ok {
789
                // Channel closed, end streaming
790
                return false
791
            }
792

793
            // Handle error messages
794
3x
            if strings.HasPrefix(chunk, "ERROR: ") {
795
3x
                c.SSEvent("error", chunk[7:]) // Remove "ERROR: " prefix
796
3x
                return false
797
3x
            }
798

799
            // Marshal the chunk to JSON to ensure newlines and special characters are preserved.
800
            jsonChunk, err := json.Marshal(chunk)
801
            if err != nil {
802
                h.logger.Error(ctx, "Failed to marshal chat stream chunk to JSON", err)
803
                return true // Continue streaming, skip this chunk
804
            }
805

806
            // Send normal content chunk in proper SSE format
807
            if _, err := fmt.Fprintf(w, "data: %s\n\n", jsonChunk); err != nil {
808
                h.logger.Error(ctx, "Failed to write chat stream data", err)
809
                return false
810
            }
811
            c.Writer.Flush()
812
            return true
813
        case <-ctx.Done():
814
            c.SSEvent("error", "Request timeout")
815
            return false
816
        }
817
    })
818
}
819

820
// Helper methods
821

822
2x
func (h *QuizHandler) selectRandomQuestionType() models.QuestionType {
823
2x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
824
2x
    types := []models.QuestionType{
825
2x
        models.Vocabulary,
826
2x
        models.FillInBlank,
827
2x
        models.QuestionAnswer,
828
2x
        models.ReadingComprehension,
829
2x
    }
830
2x
    return types[rand.Intn(len(types))]
831
2x
}
832

833
// selectRandomQuestionTypeExcluding returns a random question type excluding the specified types
834
func (h *QuizHandler) selectRandomQuestionTypeExcluding(excludeTypes ...models.QuestionType) models.QuestionType {
835
    availableTypes := []models.QuestionType{
836
        models.Vocabulary,
837
        models.FillInBlank,
838
        models.QuestionAnswer,
839
        models.ReadingComprehension,
840
    }
841

842
    // Filter out excluded types
843
    for _, excludeType := range excludeTypes {
844
        for i, availableType := range availableTypes {
845
            if availableType == excludeType {
846
                availableTypes = append(availableTypes[:i], availableTypes[i+1:]...)
847
                break
848
            }
849
        }
850
    }
851

852
    if len(availableTypes) == 0 {
853
        return models.Vocabulary // Default fallback
854
    }
855

856
    return availableTypes[rand.Intn(len(availableTypes))]
857
}
858

859
// GetWorkerStatus returns worker status and error information for the current user
860
2x
func (h *QuizHandler) GetWorkerStatus(c *gin.Context) {
861
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_worker_status")
862
2x
    defer observability.FinishSpan(span, nil)
863
2x

864
2x
    userID, exists := h.getUserIDFromSession(c)
865
2x
    if !exists {
866
        HandleAppError(c, contextutils.ErrUnauthorized)
867
        return
868
    }
869

870
2x
    span.SetAttributes(observability.AttributeUserID(userID))
871
2x

872
2x
    // Get worker health information
873
2x
    workerHealth, err := h.workerService.GetWorkerHealth(ctx)
874
2x
    if err != nil {
875
        h.logger.Error(ctx, "Failed to get worker health", err)
876
        HandleAppError(c, contextutils.WrapError(err, "failed to get worker status"))
877
        return
878
    }
879

880
    // Check if user is paused
881
2x
    userPaused, err := h.workerService.IsUserPaused(ctx, userID)
882
2x
    if err != nil {
883
        h.logger.Error(ctx, "Failed to check user pause status", err, nil)
884
        userPaused = false // Default to not paused if check fails
885
    }
886

887
    // Check if global pause is active
888
2x
    globalPaused, err := h.workerService.IsGlobalPaused(ctx)
889
2x
    if err != nil {
890
        h.logger.Error(ctx, "Failed to check global pause status", err, nil)
891
        globalPaused = false // Default to not paused if check fails
892
    }
893

894
    // Extract relevant information for the user
895
2x
    response := gin.H{
896
2x
        "has_errors":         false,
897
2x
        "error_message":      "",
898
2x
        "global_paused":      globalPaused,
899
2x
        "user_paused":        userPaused,
900
2x
        "healthy_workers":    workerHealth["healthy_count"],
901
2x
        "total_workers":      workerHealth["total_count"],
902
2x
        "last_error_details": "",
903
2x
        "worker_running":     false,
904
2x
    }
905
2x

906
2x
    // Check for worker errors
907
2x
    if workerInstances, ok := workerHealth["worker_instances"].([]map[string]interface{}); ok {
908
2x
        for _, instance := range workerInstances {
909
            if lastError, hasError := instance["last_run_error"]; hasError && lastError != nil {
910
                // Only handle string type
911
                if errorStr, ok := lastError.(string); ok && errorStr != "" {
912
                    response["has_errors"] = true
913
                    response["error_message"] = "Worker encountered errors during question generation"
914
                    response["last_error_details"] = errorStr
915
                    break
916
                }
917
            }
918
            if isRunning, ok := instance["is_running"].(bool); ok && isRunning {
919
                response["worker_running"] = true
920
            }
921
        }
922
    }
923

924
2x
    c.JSON(http.StatusOK, response)
925
}
926

927
// Helper functions for enhanced progress information
928

929
18x
func (h *QuizHandler) getWorkerStatusForUser(ctx context.Context, userID int) (*api.WorkerStatus, error) {
930
18x
    // Get worker health information
931
18x
    workerHealth, err := h.workerService.GetWorkerHealth(ctx)
932
18x
    if err != nil {
933
        return nil, err
934
    }
935

936
    // Check if user is paused
937
18x
    userPaused, err := h.workerService.IsUserPaused(ctx, userID)
938
18x
    if err != nil {
939
        userPaused = false // Default to not paused if check fails
940
    }
941

942
    // Check if global pause is active
943
18x
    globalPaused, err := h.workerService.IsGlobalPaused(ctx)
944
18x
    if err != nil {
945
        globalPaused = false // Default to not paused if check fails
946
    }
947

948
    // Determine worker status
949
18x
    var status api.WorkerStatusStatus
950
18x
    var errorMessage *string
951
18x

952
18x
    if globalPaused {
953
        status = api.WorkerStatusStatusIdle // Use idle for paused state
954
    } else if userPaused {
955
        status = api.WorkerStatusStatusIdle // Use idle for paused state
956
    } else {
957
18x
        status = api.WorkerStatusStatusIdle // Default to idle
958
18x
        // Check for worker errors and actual activity
959
18x
        if workerInstances, ok := workerHealth["worker_instances"].([]map[string]interface{}); ok {
960
18x
            for _, instance := range workerInstances {
961
                // Check for errors first
962
                if lastError, hasError := instance["last_run_error"]; hasError && lastError != nil {
963
                    if errorStr, ok := lastError.(string); ok && errorStr != "" {
964
                        // For errors, we'll use idle status but set the error message
965
                        status = api.WorkerStatusStatusIdle
966
                        errorMessage = &errorStr
967
                        break
968
                    }
969
                }
970

971
                // Check if worker is running AND has recent activity
972
                if isRunning, ok := instance["is_running"].(bool); ok && isRunning {
973
                    // Only set to busy if the worker is actually active (not just running but idle)
974
                    // We'll check if there's recent activity or if the worker is actively generating
975
                    if lastHeartbeat, hasHeartbeat := instance["last_heartbeat"]; hasHeartbeat && lastHeartbeat != nil {
976
                        if heartbeatStr, ok := lastHeartbeat.(string); ok {
977
                            if heartbeat, err := time.Parse(time.RFC3339, heartbeatStr); err == nil {
978
                                // Consider busy if heartbeat is very recent (within last 30 seconds)
979
                                if time.Since(heartbeat) < 30*time.Second {
980
                                    status = api.WorkerStatusStatusBusy
981
                                }
982
                            }
983
                        }
984
                    }
985
                }
986
            }
987
        }
988
    }
989

990
    // Get last heartbeat
991
18x
    var lastHeartbeat *time.Time
992
18x
    if workerInstances, ok := workerHealth["worker_instances"].([]map[string]interface{}); ok && len(workerInstances) > 0 {
993
        if heartbeatStr, ok := workerInstances[0]["last_heartbeat"].(string); ok {
994
            if heartbeat, err := time.Parse(time.RFC3339, heartbeatStr); err == nil {
995
                lastHeartbeat = &heartbeat
996
            }
997
        }
998
    }
999

1000
18x
    return &api.WorkerStatus{
1001
18x
        Status:        &status,
1002
18x
        LastHeartbeat: formatTimePointer(lastHeartbeat),
1003
18x
        ErrorMessage:  errorMessage,
1004
18x
    }, nil
1005
}
1006

1007
18x
func (h *QuizHandler) getPriorityInsightsForUser(ctx context.Context, userID int) (*api.PriorityInsights, error) {
1008
18x
    // Get priority distribution for the user
1009
18x
    priorityDistribution, err := h.learningService.GetUserPriorityScoreDistribution(ctx, userID)
1010
18x
    if err != nil {
1011
        return nil, err
1012
    }
1013

1014
    // Extract counts from distribution
1015
18x
    highCount := 0
1016
18x
    mediumCount := 0
1017
18x
    lowCount := 0
1018
18x
    totalCount := 0
1019
18x

1020
18x
    if high, ok := priorityDistribution["high"].(int); ok {
1021
18x
        highCount = high
1022
18x
        totalCount += high
1023
18x
    }
1024
18x
    if medium, ok := priorityDistribution["medium"].(int); ok {
1025
18x
        mediumCount = medium
1026
18x
        totalCount += medium
1027
18x
    }
1028
18x
    if low, ok := priorityDistribution["low"].(int); ok {
1029
18x
        lowCount = low
1030
18x
        totalCount += low
1031
18x
    }
1032

1033
18x
    return &api.PriorityInsights{
1034
18x
        TotalQuestionsInQueue:   &totalCount,
1035
18x
        HighPriorityQuestions:   &highCount,
1036
18x
        MediumPriorityQuestions: &mediumCount,
1037
18x
        LowPriorityQuestions:    &lowCount,
1038
18x
    }, nil
1039
}
1040

1041
18x
func (h *QuizHandler) getGenerationFocusForUser(ctx context.Context, userID int) (*api.GenerationFocus, error) {
1042
18x
    // Get user's AI configuration
1043
18x
    user, err := h.userService.GetUserByID(ctx, userID)
1044
18x
    if err != nil {
1045
        return nil, err
1046
    }
1047

1048
    // Get current generation model
1049
18x
    model := "default"
1050
18x
    if user.AIModel.Valid && user.AIModel.String != "" {
1051
17x
        model = user.AIModel.String
1052
17x
    }
1053

1054
    // Get last generation time (simplified - could be enhanced with actual generation logs)
1055
18x
    lastGenerationTime := time.Now().Add(-time.Hour) // Placeholder
1056
18x

1057
18x
    // Get generation rate (simplified - could be enhanced with actual metrics)
1058
18x
    generationRate := float32(2.5) // Placeholder: average questions per minute
1059
18x

1060
18x
    return &api.GenerationFocus{
1061
18x
        CurrentGenerationModel: &model,
1062
18x
        LastGenerationTime:     formatTimePtr(lastGenerationTime),
1063
18x
        GenerationRate:         &generationRate,
1064
18x
    }, nil
1065
}
1066

1067
18x
func (h *QuizHandler) getHighPriorityTopicsForUser(ctx context.Context, userID int) ([]string, error) {
1068
18x
    // Get high priority topics from learning service
1069
18x
    topics, err := h.learningService.GetHighPriorityTopics(ctx, userID)
1070
18x
    if err != nil {
1071
        return nil, err
1072
    }
1073
18x
    return topics, nil
1074
}
1075

1076
18x
func (h *QuizHandler) getGapAnalysisForUser(ctx context.Context, userID int) (map[string]interface{}, error) {
1077
18x
    // Get gap analysis from learning service
1078
18x
    gapAnalysis, err := h.learningService.GetGapAnalysis(ctx, userID)
1079
18x
    if err != nil {
1080
        return nil, err
1081
    }
1082
18x
    return gapAnalysis, nil
1083
}
1084

1085
18x
func (h *QuizHandler) getPriorityDistributionForUser(ctx context.Context, userID int) (map[string]int, error) {
1086
18x
    // Get priority distribution from learning service
1087
18x
    distribution, err := h.learningService.GetPriorityDistribution(ctx, userID)
1088
18x
    if err != nil {
1089
        return nil, err
1090
    }
1091
18x
    return distribution, nil
1092
}
1093

1094
26x
func convertLearningPreferencesToAPI(prefs *models.UserLearningPreferences) *api.UserLearningPreferences {
1095
26x
    out := &api.UserLearningPreferences{
1096
26x
        FocusOnWeakAreas:     prefs.FocusOnWeakAreas,
1097
26x
        FreshQuestionRatio:   float32(prefs.FreshQuestionRatio),
1098
26x
        KnownQuestionPenalty: float32(prefs.KnownQuestionPenalty),
1099
26x
        ReviewIntervalDays:   prefs.ReviewIntervalDays,
1100
26x
        WeakAreaBoost:        float32(prefs.WeakAreaBoost),
1101
26x
        DailyReminderEnabled: prefs.DailyReminderEnabled,
1102
26x
    }
1103
26x
    if prefs.TTSVoice != "" {
1104
1x
        v := prefs.TTSVoice
1105
1x
        out.TtsVoice = &v
1106
1x
    }
1107
26x
    if prefs.DailyGoal > 0 {
1108
22x
        dg := prefs.DailyGoal
1109
22x
        out.DailyGoal = &dg
1110
22x
    }
1111
26x
    return out
1112
}
1113


			
quizapp internal handlers worker_admin_handler.go
31.4%
Statements
11/35
1
package handlers
2

3
import (
4
    "fmt"
5
    "net/http"
6
    "sort"
7
    "strings"
8
    "time"
9

10
    "quizapp/internal/observability"
11

12
    "github.com/gin-gonic/gin"
13
)
14

15
// RouteInfo represents information about a single route
16
type RouteInfo struct {
17
    Method      string `json:"method"`
18
    Path        string `json:"path"`
19
    HandlerName string `json:"handler_name"`
20
}
21

22
// RouteListingHandler generates automatic route listings
23
type RouteListingHandler struct {
24
    serviceName string
25
    routes      []RouteInfo
26
}
27

28
// NewRouteListingHandler creates a new route listing handler
29
27x
func NewRouteListingHandler(serviceName string) *RouteListingHandler {
30
27x
    return &RouteListingHandler{
31
27x
        serviceName: serviceName,
32
27x
        routes:      []RouteInfo{},
33
27x
    }
34
27x
}
35

36
// CollectRoutes extracts all routes from a Gin engine
37
25x
func (h *RouteListingHandler) CollectRoutes(engine *gin.Engine) {
38
25x
    h.routes = []RouteInfo{}
39
25x

40
25x
    // Get all routes from the Gin engine
41
25x
    routes := engine.Routes()
42
25x

43
25x
    for _, route := range routes {
44
865x
        // Skip internal Gin routes
45
865x
        if strings.HasPrefix(route.Path, "/debug/") {
46
            continue
47
        }
48

49
865x
        h.routes = append(h.routes, RouteInfo{
50
865x
            Method:      route.Method,
51
865x
            Path:        route.Path,
52
865x
            HandlerName: route.Handler,
53
865x
        })
54
    }
55

56
    // Sort routes by path for better organization
57
25x
    sort.Slice(h.routes, func(i, j int) bool {
58
4727x
        return h.routes[i].Path < h.routes[j].Path
59
4727x
    })
60
}
61

62
// GetRouteListingPage shows all available routes as HTML
63
func (h *RouteListingHandler) GetRouteListingPage(c *gin.Context) {
64
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_route_listing_page")
65
    defer observability.FinishSpan(span, nil)
66
    html := h.generateHTML()
67
    // Add no-cache headers
68
    c.Header("Content-Type", "text/html; charset=utf-8")
69
    c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
70
    c.Header("Pragma", "no-cache")
71
    c.Header("Expires", "0")
72
    c.String(http.StatusOK, html)
73
}
74

75
// GetRouteListingJSON returns the route listing as JSON
76
5x
func (h *RouteListingHandler) GetRouteListingJSON(c *gin.Context) {
77
5x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_route_listing_json")
78
5x
    defer observability.FinishSpan(span, nil)
79
5x
    c.JSON(http.StatusOK, h.routes)
80
5x
}
81

82
// generateHTML creates an HTML page listing all routes
83
func (h *RouteListingHandler) generateHTML() string {
84
    var html strings.Builder
85

86
    html.WriteString(`<!DOCTYPE html>
87
<html lang="en">
88
<head>
89
    <meta charset="UTF-8">
90
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
91
    <title>` + h.serviceName + ` - Available Routes</title>
92
    <style>
93
        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; padding: 20px; background-color: #f8f9fa; color: #212529; }
94
        .container { max-width: 1200px; margin: auto; background: #fff; padding: 30px; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.05); }
95
        h1 { color: #0056b3; border-bottom: 2px solid #dee2e6; padding-bottom: 10px; margin-bottom: 30px; }
96
        .service-info { background: #e7f3ff; padding: 15px; border-radius: 5px; margin-bottom: 30px; }
97
        .route-table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
98
        .route-table th, .route-table td { padding: 12px; text-align: left; border-bottom: 1px solid #dee2e6; }
99
        .route-table th { background-color: #f8f9fa; font-weight: 600; color: #495057; }
100
        .route-table tr:nth-child(even) { background-color: #f8f9fa; }
101
        .route-table tr:hover { background-color: #e9ecef; }
102
        .method { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; min-width: 60px; text-align: center; }
103
        .method-get { background-color: #d4edda; color: #155724; }
104
        .method-post { background-color: #cce5ff; color: #004085; }
105
        .method-put { background-color: #fff3cd; color: #856404; }
106
        .method-delete { background-color: #f8d7da; color: #721c24; }
107
        .method-patch { background-color: #e2e3e5; color: #383d41; }
108
        .path { font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; font-size: 14px; color: #6f42c1; }
109
        .clickable-path { cursor: pointer; text-decoration: underline; }
110
        .clickable-path:hover { background-color: #f8f9fa; }
111
        .footer { margin-top: 30px; text-align: center; color: #6c757d; font-size: 14px; }
112
        .stats { display: flex; gap: 20px; margin-bottom: 20px; }
113
        .stat-box { background: #ffffff; border: 1px solid #dee2e6; padding: 15px; border-radius: 5px; text-align: center; flex: 1; }
114
        .stat-number { font-size: 24px; font-weight: bold; color: #0056b3; }
115
        .stat-label { color: #6c757d; font-size: 14px; }
116
    </style>
117
</head>
118
<body>
119
    <div class="container">
120
        <h1>` + h.serviceName + ` Service - Available Routes</h1>
121

122
        <div class="service-info">
123
            <strong>Service:</strong> ` + h.serviceName + `<br>
124
            <strong>Generated:</strong> ` + time.Now().Format("2006-01-02 15:04:05") + `<br>
125
            <strong>Total Routes:</strong> ` + fmt.Sprintf("%d", len(h.routes)) + `
126
        </div>
127

128
        <div class="stats">
129
            <div class="stat-box">
130
                <div class="stat-number">` + fmt.Sprintf("%d", len(h.routes)) + `</div>
131
                <div class="stat-label">Total Routes</div>
132
            </div>
133
            <div class="stat-box">
134
                <div class="stat-number">` + fmt.Sprintf("%d", h.countMethods("GET")) + `</div>
135
                <div class="stat-label">GET Routes</div>
136
            </div>
137
            <div class="stat-box">
138
                <div class="stat-number">` + fmt.Sprintf("%d", h.countMethods("POST")) + `</div>
139
                <div class="stat-label">POST Routes</div>
140
            </div>
141
        </div>
142

143
        <table class="route-table">
144
            <thead>
145
                <tr>
146
                    <th>Method</th>
147
                    <th>Path</th>
148
                    <th>Handler</th>
149
                </tr>
150
            </thead>
151
            <tbody>`)
152

153
    for _, route := range h.routes {
154
        methodClass := "method-" + strings.ToLower(route.Method)
155
        pathClass := "path"
156

157
        // Make paths clickable for GET routes
158
        if route.Method == "GET" {
159
            pathClass += " clickable-path"
160
        }
161

162
        html.WriteString(fmt.Sprintf(`
163
                <tr>
164
                    <td><span class="method %s">%s</span></td>
165
                    <td><span class="%s" onclick="navigateToRoute('%s', '%s')">%s</span></td>
166
                    <td>%s</td>
167
                </tr>`,
168
            methodClass, route.Method,
169
            pathClass, route.Method, route.Path, route.Path,
170
            route.HandlerName,
171
        ))
172
    }
173

174
    html.WriteString(`
175
            </tbody>
176
        </table>
177

178
        <div class="footer">
179
            <p>Click on any GET route path to navigate to it | <a href="/?json=true">View as JSON</a></p>
180
        </div>
181
    </div>
182

183
    <script>
184
        function navigateToRoute(method, path) {
185
            if (method === 'GET') {
186
                window.location.href = path;
187
            } else {
188
                alert('Only GET routes can be navigated to directly. Use API client for ' + method + ' requests.');
189
            }
190
        }
191
    </script>
192
</body>
193
</html>`)
194

195
    return html.String()
196
}
197

198
// countMethods counts routes by HTTP method
199
func (h *RouteListingHandler) countMethods(method string) int {
200
    count := 0
201
    for _, route := range h.routes {
202
        if route.Method == method {
203
            count++
204
        }
205
    }
206
    return count
207
}
208


			
quizapp internal handlers worker_admin_handler.go
92.9%
Statements
169/182
1
package handlers
2

3
import (
4
    "encoding/json"
5
    "net/http"
6
    "os"
7
    "strings"
8
    "time"
9

10
    "github.com/gin-contrib/cors"
11
    "github.com/gin-contrib/secure"
12
    "github.com/gin-contrib/sessions"
13
    "github.com/gin-contrib/sessions/cookie"
14
    "github.com/gin-gonic/gin"
15
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
16

17
    "quizapp/internal/config"
18
    "quizapp/internal/middleware"
19
    "quizapp/internal/observability"
20
    "quizapp/internal/services"
21
    "quizapp/internal/version"
22
)
23

24
// IMPORTANT: When adding new API endpoints, make sure to:
25
// 1. Add them to swagger.yaml with proper documentation
26
// 2. Run `task generate-api-types` to regenerate types
27
// 3. Update any relevant tests
28
// 4. Consider if the endpoint should be public or admin-only
29

30
// NewRouter creates a new router factory with all the necessary middleware and routes
31
func NewRouter(
32
    cfg *config.Config,
33
    userService services.UserServiceInterface,
34
    questionService services.QuestionServiceInterface,
35
    learningService services.LearningServiceInterface,
36
    aiService services.AIServiceInterface,
37
    workerService services.WorkerServiceInterface,
38
    dailyQuestionService services.DailyQuestionServiceInterface,
39
    oauthService *services.OAuthService,
40
    generationHintService services.GenerationHintServiceInterface,
41
    logger *observability.Logger,
42
11x
) *gin.Engine {
43
11x
    // Setup Gin router
44
11x
    router := gin.New()
45
11x
    router.Use(gin.Recovery())
46
11x

47
11x
    // Add HTTP request logging middleware using our observability logger
48
11x
    router.Use(func(c *gin.Context) {
49
397x
        start := time.Now()
50
397x

51
397x
        // Process request
52
397x
        c.Next()
53
397x

54
397x
        // Log request details using our observability logger
55
397x
        latency := time.Since(start)
56
397x
        statusCode := c.Writer.Status()
57
397x
        clientIP := c.ClientIP()
58
397x
        method := c.Request.Method
59
397x
        path := c.Request.URL.Path
60
397x

61
397x
        // Create structured log entry
62
397x
        fields := map[string]interface{}{
63
397x
            "http.method":      method,
64
397x
            "http.path":        path,
65
397x
            "http.status_code": statusCode,
66
397x
            "http.latency_ms":  latency.Milliseconds(),
67
397x
            "http.client_ip":   clientIP,
68
397x
            "http.user_agent":  c.Request.UserAgent(),
69
397x
        }
70
397x

71
397x
        // Add error message if present
72
397x
        if len(c.Errors) > 0 {
73
            fields["http.error"] = c.Errors.String()
74
        }
75

76
        // For failed requests (4xx and 5xx), capture response body for debugging
77
397x
        if statusCode >= 400 {
78
88x
            // Get response body for error requests
79
88x
            if c.Writer.Size() > 0 {
80
88x
                // Try to capture response body for debugging
81
88x
                // Note: This is a best effort since the response may have already been written
82
88x
                fields["http.response_size"] = c.Writer.Size()
83
88x
            }
84

85
            // Add more context for 5xx errors
86
88x
            if statusCode >= 500 {
87
                fields["http.error_type"] = "server_error"
88
                // Log additional context that might help debugging
89
                if c.Request.Body != nil {
90
                    fields["http.request_has_body"] = true
91
                }
92
88x
            } else {
93
88x
                fields["http.error_type"] = "client_error"
94
88x
            }
95
        }
96

97
        // Log using our observability logger (goes to both stdout and OTLP)
98
        // Use appropriate log level based on status code
99
397x
        if statusCode >= 500 {
100
            logger.Error(c.Request.Context(), "HTTP request failed", nil, fields)
101
        } else if statusCode >= 400 {
102
            logger.Warn(c.Request.Context(), "HTTP request warning", fields)
103
88x
        } else {
104
309x
            logger.Info(c.Request.Context(), "HTTP request", fields)
105
309x
        }
106
    })
107

108
    // Health check endpoint (defined before any middleware)
109
11x
    router.GET("/health", func(c *gin.Context) {
110
1x
        c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "backend"})
111
1x
    })
112

113
    // Add OpenTelemetry middleware for HTTP tracing and context propagation with automatic error attributes
114
11x
    router.Use(observability.GinMiddlewareWithErrorHandling("quiz-backend"))
115
11x

116
11x
    // Add response validation middleware for API endpoints
117
11x
    router.Use(middleware.ResponseValidationMiddleware(logger))
118
11x

119
11x
    // Swagger documentation (defined before middleware)
120
11x
    router.StaticFile("/swagger.yaml", "./swagger.yaml")
121
11x
    router.StaticFile("/swaggerz", "./swaggerz.html")
122
11x

123
11x
    // Disable automatic redirection for trailing slashes, which is better for APIs
124
11x
    router.RedirectTrailingSlash = false
125
11x

126
11x
    // Setup CORS middleware
127
11x
    corsConfig := cors.DefaultConfig()
128
11x
    corsConfig.AllowOrigins = cfg.Server.CORSOrigins
129
11x
    corsConfig.AllowCredentials = true
130
11x
    corsConfig.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "X-Requested-With"}
131
11x
    corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
132
11x
    router.Use(cors.New(corsConfig))
133
11x

134
11x
    // Setup session middleware
135
11x
    store := cookie.NewStore([]byte(cfg.Server.SessionSecret))
136
11x
    // Configure session options for security
137
11x
    sessionOpts := sessions.Options{
138
11x
        Path:     config.SessionPath,
139
11x
        MaxAge:   int(config.SessionMaxAge.Seconds()),
140
11x
        HttpOnly: config.SessionHTTPOnly,
141
11x
        Secure:   config.SessionSecure, // Set to true in production with HTTPS
142
11x
    }
143
11x
    if cfg.Server.Debug {
144
        sessionOpts.SameSite = http.SameSiteDefaultMode
145
    } else {
146
11x
        sessionOpts.SameSite = http.SameSiteLaxMode
147
11x
        sessionOpts.Secure = true
148
11x
    }
149
11x
    store.Options(sessionOpts)
150
11x
    router.Use(sessions.Sessions(config.SessionName, store))
151
11x

152
11x
    // Setup Gin mode
153
11x
    gin.SetMode(gin.ReleaseMode)
154
11x
    if cfg.Server.Debug {
155
        gin.SetMode(gin.DebugMode)
156
    }
157

158
    // Security middleware
159
11x
    secureConfig := secure.DefaultConfig()
160
11x
    secureConfig.SSLRedirect = false
161
11x
    secureConfig.ContentSecurityPolicy = config.DefaultCSP
162
11x
    router.Use(secure.New(secureConfig))
163
11x

164
11x
    // Serve all static assets (JS, fonts, CSS, etc.) from /backend/*filepath
165
11x
    // Note: Static assets are now served from the frontend build
166
11x

167
11x
    // Initialize handlers
168
11x
    authHandler := NewAuthHandler(userService, oauthService, cfg, logger)
169
11x
    emailService := services.CreateEmailService(cfg, logger)
170
11x
    settingsHandler := NewSettingsHandler(userService, aiService, learningService, emailService, cfg, logger)
171
11x
    quizHandler := NewQuizHandler(userService, questionService, aiService, learningService, workerService, generationHintService, cfg, logger)
172
11x
    dailyQuestionHandler := NewDailyQuestionHandler(userService, dailyQuestionService, cfg, logger)
173
11x
    adminHandler := NewAdminHandlerWithLogger(userService, questionService, aiService, cfg, learningService, workerService, logger)
174
11x
    userAdminHandler := NewUserAdminHandler(userService, cfg, logger)
175
11x

176
11x
    // V1 routes (matching swagger spec)
177
11x
    v1 := router.Group("/v1")
178
11x
    {
179
11x
        // Version aggregation endpoint (no auth)
180
11x
        v1.GET("/version", func(c *gin.Context) {
181
2x
            backendVersion := gin.H{
182
2x
                "service":   "backend",
183
2x
                "version":   version.Version,
184
2x
                "commit":    version.Commit,
185
2x
                "buildTime": version.BuildTime,
186
2x
            }
187
2x
            workerInternalURL := os.Getenv("WORKER_INTERNAL_URL")
188
2x
            if workerInternalURL == "" {
189
2x
                workerInternalURL = cfg.Server.WorkerInternalURL // fallback
190
2x
            }
191
            // Use instrumented HTTP client for tracing
192
2x
            client := &http.Client{
193
2x
                Transport: otelhttp.NewTransport(http.DefaultTransport),
194
2x
            }
195
2x
            req, err := http.NewRequest("GET", workerInternalURL+"/v1/version", nil)
196
2x
            var workerResp *http.Response
197
2x
            if err == nil {
198
2x
                req = req.WithContext(c.Request.Context())
199
2x
                workerResp, err = client.Do(req)
200
2x
            }
201
2x
            var workerVersion interface{}
202
2x
            if err == nil && workerResp.StatusCode == http.StatusOK {
203
                defer func() { _ = workerResp.Body.Close() }()
204
                if err := json.NewDecoder(workerResp.Body).Decode(&workerVersion); err != nil {
205
                    workerVersion = gin.H{"error": "Failed to decode worker version"}
206
                }
207
2x
            } else {
208
2x
                workerVersion = gin.H{"error": "Worker unavailable"}
209
2x
            }
210
2x
            c.JSON(http.StatusOK, gin.H{
211
2x
                "backend": backendVersion,
212
2x
                "worker":  workerVersion,
213
2x
            })
214
        })
215
11x
        auth := v1.Group("/auth")
216
11x
        {
217
11x
            auth.POST("/login", middleware.RequestValidationMiddleware(logger), authHandler.Login)
218
11x
            auth.POST("/logout", authHandler.Logout)
219
11x
            auth.GET("/status", authHandler.Status)
220
11x
            auth.GET("/check", middleware.RequireAuth(), authHandler.Check)
221
11x
            auth.POST("/signup", middleware.RequestValidationMiddleware(logger), authHandler.Signup)
222
11x
            auth.GET("/signup/status", authHandler.SignupStatus)
223
11x
            auth.GET("/google/login", authHandler.GoogleLogin)
224
11x
            auth.GET("/google/callback", authHandler.GoogleCallback)
225
11x
        }
226
11x
        quiz := v1.Group("/quiz")
227
11x
        quiz.Use(middleware.RequireAuth())
228
11x
        quiz.Use(middleware.RequestValidationMiddleware(logger))
229
11x
        {
230
11x
            quiz.GET("/question", quizHandler.GetQuestion)
231
11x
            quiz.GET("/question/:id", quizHandler.GetQuestion)
232
11x
            quiz.POST("/question/:id/report", quizHandler.ReportQuestion)
233
11x
            quiz.POST("/question/:id/mark-known", quizHandler.MarkQuestionAsKnown)
234
11x
            quiz.POST("/answer", quizHandler.SubmitAnswer)
235
11x
            quiz.GET("/progress", quizHandler.GetProgress)
236
11x
            quiz.GET("/worker-status", quizHandler.GetWorkerStatus)
237
11x
            quiz.POST("/chat/stream", quizHandler.ChatStream)
238
11x
        }
239
11x
        daily := v1.Group("/daily")
240
11x
        daily.Use(middleware.RequireAuth())
241
11x
        daily.Use(middleware.RequestValidationMiddleware(logger))
242
11x
        {
243
11x
            daily.GET("/questions/:date", dailyQuestionHandler.GetDailyQuestions)
244
11x
            daily.POST("/questions/:date/complete/:questionId", dailyQuestionHandler.MarkQuestionCompleted)
245
11x
            daily.DELETE("/questions/:date/complete/:questionId", dailyQuestionHandler.ResetQuestionCompleted)
246
11x
            daily.POST("/questions/:date/answer/:questionId", dailyQuestionHandler.SubmitDailyQuestionAnswer)
247
11x
            daily.GET("/history/:questionId", dailyQuestionHandler.GetQuestionHistory)
248
11x
            daily.GET("/dates", dailyQuestionHandler.GetAvailableDates)
249
11x
            daily.GET("/progress/:date", dailyQuestionHandler.GetDailyProgress)
250
11x
            // Note: Assignment is handled automatically by the worker
251
11x
        }
252
11x
        settings := v1.Group("/settings")
253
11x
        {
254
11x
            settings.GET("/ai-providers", middleware.RequireAuth(), settingsHandler.GetProviders)
255
11x
            settings.GET("/levels", settingsHandler.GetLevels)
256
11x
            settings.GET("/languages", settingsHandler.GetLanguages)
257
11x
            settings.POST("/test-ai", middleware.RequireAuth(), middleware.RequestValidationMiddleware(logger), settingsHandler.TestAIConnection)
258
11x
            settings.POST("/test-email", middleware.RequireAuth(), middleware.RequestValidationMiddleware(logger), settingsHandler.SendTestEmail)
259
11x
            settings.PUT("", middleware.RequireAuth(), middleware.RequestValidationMiddleware(logger), settingsHandler.UpdateUserSettings)
260
11x
            settings.GET("/api-key/:provider", middleware.RequireAuth(), settingsHandler.CheckAPIKeyAvailability)
261
11x
        }
262
11x
        preferences := v1.Group("/preferences")
263
11x
        preferences.Use(middleware.RequireAuth())
264
11x
        preferences.Use(middleware.RequestValidationMiddleware(logger))
265
11x
        {
266
11x
            preferences.GET("/learning", settingsHandler.GetLearningPreferences)
267
11x
            preferences.PUT("/learning", settingsHandler.UpdateLearningPreferences)
268
11x
        }
269

270
        // User management endpoints (non-admin)
271
11x
        userz := v1.Group("/userz")
272
11x
        {
273
11x
            userz.PUT("/profile", middleware.RequireAuth(), middleware.RequestValidationMiddleware(logger), userAdminHandler.UpdateCurrentUserProfile)
274
11x
        }
275

276
        // Admin endpoints
277
11x
        admin := v1.Group("/admin")
278
11x
        admin.Use(middleware.RequireAdmin(userService))
279
11x
        admin.Use(middleware.RequestValidationMiddleware(logger))
280
11x
        {
281
11x
            // Backend admin endpoints
282
11x
            backend := admin.Group("/backend")
283
11x
            {
284
11x
                // Backend admin page
285
11x
                backend.GET("", adminHandler.GetBackendAdminPage)
286
11x
                // User management (admin only)
287
11x
                backend.GET("/userz", userAdminHandler.GetAllUsers)
288
11x
                backend.GET("/userz/paginated", userAdminHandler.GetUsersPaginated)
289
11x
                backend.POST("/userz", userAdminHandler.CreateUser)
290
11x
                backend.PUT("/userz/:id", userAdminHandler.UpdateUser)
291
11x
                backend.DELETE("/userz/:id", userAdminHandler.DeleteUser)
292
11x
                backend.POST("/userz/:id/reset-password", userAdminHandler.ResetUserPassword)
293
11x

294
11x
                // Role management endpoints
295
11x
                backend.GET("/roles", adminHandler.GetRoles)
296
11x
                backend.GET("/userz/:id/roles", adminHandler.GetUserRoles)
297
11x
                backend.POST("/userz/:id/roles", adminHandler.AssignRole)
298
11x
                backend.DELETE("/userz/:id/roles/:roleId", adminHandler.RemoveRole)
299
11x

300
11x
                // Admin dashboard data
301
11x
                backend.GET("/dashboard", adminHandler.GetBackendAdminData)
302
11x
                backend.GET("/ai-concurrency", adminHandler.GetAIConcurrencyStats)
303
11x

304
11x
                // Question management
305
11x
                backend.GET("/questions/:id", adminHandler.GetQuestion)
306
11x
                backend.GET("/questions/:id/users", adminHandler.GetUsersForQuestion)
307
11x
                backend.PUT("/questions/:id", adminHandler.UpdateQuestion)
308
11x
                backend.DELETE("/questions/:id", adminHandler.DeleteQuestion)
309
11x
                backend.POST("/questions/:id/assign-users", adminHandler.AssignUsersToQuestion)
310
11x
                backend.POST("/questions/:id/unassign-users", adminHandler.UnassignUsersFromQuestion)
311
11x
                backend.GET("/questions/paginated", adminHandler.GetQuestionsPaginated)
312
11x
                backend.GET("/questions", adminHandler.GetAllQuestions)
313
11x
                backend.GET("/reported-questions", adminHandler.GetReportedQuestionsPaginated)
314
11x
                backend.POST("/questions/:id/fix", adminHandler.MarkQuestionAsFixed)
315
11x
                backend.POST("/questions/:id/ai-fix", adminHandler.FixQuestionWithAI)
316
11x

317
11x
                // Data management
318
11x
                backend.POST("/clear-user-data", adminHandler.ClearUserData)
319
11x
                backend.POST("/clear-database", adminHandler.ClearDatabase)
320
11x
                backend.POST("/userz/:id/clear", adminHandler.ClearUserDataForUser)
321
11x
            }
322

323
        }
324
    }
325

326
    // Config dump endpoint
327
11x
    router.GET("/configz", adminHandler.GetConfigz)
328
11x

329
11x
    // Serve frontend static files
330
11x
    router.Static("/assets", "./frontend/dist/assets")
331
11x
    router.StaticFile("/favicon.svg", "./frontend/dist/favicon.svg")
332
11x
    router.StaticFile("/fonts", "./frontend/dist/fonts")
333
11x

334
11x
    // Catch-all route for SPA - serve index.html for any route that doesn't match API routes
335
11x
    router.NoRoute(func(c *gin.Context) {
336
11x
        // Don't serve index.html for API routes
337
11x
        if strings.HasPrefix(c.Request.URL.Path, "/v1/") ||
338
11x
            strings.HasPrefix(c.Request.URL.Path, "/configz") ||
339
11x
            strings.HasPrefix(c.Request.URL.Path, "/swagger") ||
340
11x
            strings.HasPrefix(c.Request.URL.Path, "/backend/") {
341
11x
            c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
342
11x
            return
343
11x
        }
344

345
        // Serve the frontend's index.html for all other routes
346
        c.File("./frontend/dist/index.html")
347
    })
348

349
    // Automatic route listing at root path
350
11x
    routeListing := NewRouteListingHandler("Backend")
351
11x
    routeListing.CollectRoutes(router)
352
11x

353
11x
    // Root path shows all available routes
354
11x
    router.GET("/", func(c *gin.Context) {
355
1x
        if c.Query("json") == "true" {
356
1x
            routeListing.GetRouteListingJSON(c)
357
1x
        } else {
358
            routeListing.GetRouteListingPage(c)
359
        }
360
    })
361

362
11x
    return router
363
}
364


			
quizapp internal handlers worker_admin_handler.go
100.0%
Statements
8/8
1
package handlers
2

3
import (
4
    "quizapp/internal/middleware"
5

6
    "github.com/gin-contrib/sessions"
7
    "github.com/gin-gonic/gin"
8
)
9

10
// GetUserIDFromSession retrieves the current user ID from the session.
11
// Returns (0, false) if not authenticated or if the stored value is invalid.
12
110x
func GetUserIDFromSession(c *gin.Context) (int, bool) {
13
110x
    session := sessions.Default(c)
14
110x
    userID := session.Get(middleware.UserIDKey)
15
110x
    if userID == nil {
16
1x
        return 0, false
17
1x
    }
18
108x
    id, ok := userID.(int)
19
108x
    if !ok {
20
1x
        return 0, false
21
1x
    }
22
106x
    return id, true
23
}
24


			
quizapp internal handlers worker_admin_handler.go
77.3%
Statements
140/181
1
package handlers
2

3
import (
4
    "fmt"
5
    "net/http"
6

7
    "quizapp/internal/api"
8
    "quizapp/internal/config"
9
    "quizapp/internal/middleware"
10
    "quizapp/internal/models"
11
    "quizapp/internal/observability"
12
    "quizapp/internal/services"
13
    "quizapp/internal/services/mailer"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/gin-contrib/sessions"
17
    "github.com/gin-gonic/gin"
18
    "go.opentelemetry.io/otel/attribute"
19
)
20

21
// SettingsHandler handles user settings related HTTP requests
22
type SettingsHandler struct {
23
    userService     services.UserServiceInterface
24
    aiService       services.AIServiceInterface
25
    learningService services.LearningServiceInterface
26
    emailService    mailer.Mailer
27
    cfg             *config.Config
28
    logger          *observability.Logger
29
}
30

31
// NewSettingsHandler creates a new SettingsHandler instance
32
26x
func NewSettingsHandler(userService services.UserServiceInterface, aiService services.AIServiceInterface, learningService services.LearningServiceInterface, emailService mailer.Mailer, cfg *config.Config, logger *observability.Logger) *SettingsHandler {
33
26x
    return &SettingsHandler{
34
26x
        userService:     userService,
35
26x
        aiService:       aiService,
36
26x
        learningService: learningService,
37
26x
        emailService:    emailService,
38
26x
        cfg:             cfg,
39
26x
        logger:          logger,
40
26x
    }
41
26x
}
42

43
// UpdateUserSettings handles updating user settings
44
16x
func (h *SettingsHandler) UpdateUserSettings(c *gin.Context) {
45
16x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "update_user_settings")
46
16x
    defer observability.FinishSpan(span, nil)
47
16x
    session := sessions.Default(c)
48
16x
    userID, ok := session.Get(middleware.UserIDKey).(int)
49
16x
    if !ok {
50
        HandleAppError(c, contextutils.ErrUnauthorized)
51
        return
52
    }
53

54
16x
    var settings api.UserSettings
55
16x
    if err := c.ShouldBindJSON(&settings); err != nil {
56
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
57
1x
            contextutils.ErrorCodeInvalidInput,
58
1x
            contextutils.SeverityWarn,
59
1x
            "Invalid request body",
60
1x
            "",
61
1x
            err,
62
1x
        ))
63
1x
        return
64
1x
    }
65

66
    // Validate that at least one meaningful field is provided
67
    // Avoid relying on generated union/raw fields that may be non-nil for an empty JSON body
68
15x
    hasAnyField := settings.Language != nil ||
69
15x
        settings.Level != nil ||
70
15x
        settings.AiProvider != nil ||
71
15x
        settings.AiModel != nil ||
72
15x
        settings.ApiKey != nil ||
73
15x
        settings.AiEnabled != nil
74
15x

75
15x
    if !hasAnyField {
76
1x
        HandleAppError(c, contextutils.ErrInvalidInput)
77
1x
        return
78
1x
    }
79

80
    // Convert api.UserSettings to models.UserSettings
81
14x
    modelSettings := models.UserSettings{}
82
14x
    if settings.Language != nil {
83
14x
        modelSettings.Language = string(*settings.Language)
84
14x
        span.SetAttributes(attribute.String("settings.language", modelSettings.Language))
85
14x
    }
86
14x
    if settings.Level != nil {
87
13x
        modelSettings.Level = string(*settings.Level)
88
13x
        span.SetAttributes(attribute.String("settings.level", modelSettings.Level))
89
13x
    }
90
14x
    if settings.AiProvider != nil {
91
9x
        modelSettings.AIProvider = *settings.AiProvider
92
9x
        span.SetAttributes(attribute.String("settings.ai_provider", modelSettings.AIProvider))
93
9x
    }
94
14x
    if settings.AiModel != nil {
95
10x
        modelSettings.AIModel = *settings.AiModel
96
10x
        span.SetAttributes(attribute.String("settings.ai_model", modelSettings.AIModel))
97
10x
    }
98
14x
    if settings.ApiKey != nil {
99
10x
        modelSettings.AIAPIKey = *settings.ApiKey
100
10x
        span.SetAttributes(attribute.Bool("settings.api_key_provided", true))
101
10x
    }
102
14x
    if settings.AiEnabled != nil {
103
10x
        modelSettings.AIEnabled = *settings.AiEnabled
104
10x
        span.SetAttributes(attribute.Bool("settings.ai_enabled", modelSettings.AIEnabled))
105
10x
    }
106

107
    // Validate level if provided (including empty string)
108
14x
    if settings.Level != nil {
109
13x
        validLevels := h.cfg.GetAllLevels()
110
13x
        isValidLevel := false
111
13x
        for _, level := range validLevels {
112
85x
            if modelSettings.Level == level {
113
10x
                isValidLevel = true
114
10x
                break
115
            }
116
        }
117

118
13x
        if !isValidLevel {
119
3x
            HandleAppError(c, contextutils.ErrInvalidFormat)
120
3x
            return
121
3x
        }
122
    }
123

124
    // Validate language if provided (including empty string)
125
11x
    if settings.Language != nil {
126
11x
        validLanguages := h.cfg.GetLanguages()
127
11x
        isValidLanguage := false
128
11x
        for _, language := range validLanguages {
129
40x
            if modelSettings.Language == language {
130
10x
                isValidLanguage = true
131
10x
                break
132
            }
133
        }
134

135
11x
        if !isValidLanguage {
136
1x
            HandleAppError(c, contextutils.ErrInvalidFormat)
137
1x
            return
138
1x
        }
139
    }
140

141
10x
    if err := h.userService.UpdateUserSettings(c.Request.Context(), userID, &modelSettings); err != nil {
142
        // Check if the error is due to user not found
143
        if contextutils.IsError(err, contextutils.ErrRecordNotFound) {
144
            HandleAppError(c, contextutils.ErrRecordNotFound)
145
            return
146
        }
147
        HandleAppError(c, contextutils.WrapError(err, "failed to update settings"))
148
        return
149
    }
150

151
10x
    c.JSON(http.StatusOK, api.SuccessResponse{Success: true})
152
}
153

154
// TestAIConnection tests the AI service connection with provided settings
155
2x
func (h *SettingsHandler) TestAIConnection(c *gin.Context) {
156
2x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "test_ai_connection")
157
2x
    defer observability.FinishSpan(span, nil)
158
2x
    session := sessions.Default(c)
159
2x
    userID, ok := session.Get(middleware.UserIDKey).(int)
160
2x
    if !ok {
161
        HandleAppError(c, contextutils.ErrUnauthorized)
162
        return
163
    }
164

165
2x
    var req api.TestAIRequest
166
2x
    if err := c.ShouldBindJSON(&req); err != nil {
167
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
168
1x
            contextutils.ErrorCodeInvalidInput,
169
1x
            contextutils.SeverityWarn,
170
1x
            "Invalid request format",
171
1x
            "",
172
1x
            err,
173
1x
        ))
174
1x
        return
175
1x
    }
176

177
    // Extract values from API request
178
1x
    provider := req.Provider
179
1x
    model := req.Model
180
1x
    apiKey := ""
181
1x
    if req.ApiKey != nil {
182
1x
        apiKey = *req.ApiKey
183
1x
    }
184

185
    // If API key is empty, try to use the saved one from the new user_api_keys table
186
1x
    if apiKey == "" {
187
        savedKey, err := h.userService.GetUserAPIKey(c.Request.Context(), userID, provider)
188
        if err != nil {
189
            HandleAppError(c, contextutils.WrapError(err, "failed to get saved API key"))
190
            return
191
        }
192
        apiKey = savedKey
193
    }
194

195
1x
    err := h.aiService.TestConnection(c.Request.Context(), provider, model, apiKey)
196
1x
    if err != nil {
197
1x
        c.JSON(http.StatusOK, gin.H{
198
1x
            "success": false,
199
1x
            "message": fmt.Sprintf("Model '%s': %s", model, err.Error()),
200
1x
        })
201
1x
        return
202
1x
    }
203

204
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "Connection successful"})
205
}
206

207
// GetProviders returns the available AI provider configurations
208
3x
func (h *SettingsHandler) GetProviders(c *gin.Context) {
209
3x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_providers")
210
3x
    defer observability.FinishSpan(span, nil)
211
3x

212
3x
    response := gin.H{
213
3x
        "providers": h.cfg.Providers,
214
3x
        "levels":    h.cfg.GetAllLevels(),
215
3x
        "languages": h.cfg.GetLanguages(),
216
3x
    }
217
3x
    c.JSON(http.StatusOK, response)
218
3x
}
219

220
// GetLevels returns the available levels and their descriptions.
221
19x
func (h *SettingsHandler) GetLevels(c *gin.Context) {
222
19x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_levels")
223
19x
    defer observability.FinishSpan(span, nil)
224
19x
    language := c.Query("language")
225
19x
    if language != "" {
226
16x
        levels := h.cfg.GetLevelsForLanguage(language)
227
16x
        descriptions := h.cfg.GetLevelDescriptionsForLanguage(language)
228
16x
        c.JSON(http.StatusOK, gin.H{
229
16x
            "levels":             levels,
230
16x
            "level_descriptions": descriptions,
231
16x
        })
232
16x
        return
233
16x
    }
234
3x
    c.JSON(http.StatusOK, gin.H{
235
3x
        "levels":             h.cfg.GetAllLevels(),
236
3x
        "level_descriptions": h.cfg.GetAllLevelDescriptions(),
237
3x
    })
238
}
239

240
// GetLanguages returns the available languages.
241
3x
func (h *SettingsHandler) GetLanguages(c *gin.Context) {
242
3x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_languages")
243
3x
    defer observability.FinishSpan(span, nil)
244
3x
    c.JSON(http.StatusOK, h.cfg.GetLanguages())
245
3x
}
246

247
// CheckAPIKeyAvailability checks if the user has a saved API key for the specified provider
248
2x
func (h *SettingsHandler) CheckAPIKeyAvailability(c *gin.Context) {
249
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "check_api_key_availability")
250
2x
    defer observability.FinishSpan(span, nil)
251
2x
    session := sessions.Default(c)
252
2x
    userID, ok := session.Get(middleware.UserIDKey).(int)
253
2x
    if !ok {
254
        HandleAppError(c, contextutils.ErrUnauthorized)
255
        return
256
    }
257

258
2x
    provider := c.Param("provider")
259
2x
    if provider == "" {
260
        HandleAppError(c, contextutils.ErrMissingRequired)
261
        return
262
    }
263

264
    // Check if user has a saved API key for this provider
265
2x
    hasAPIKey, err := h.userService.HasUserAPIKey(ctx, userID, provider)
266
2x
    if err != nil {
267
        h.logger.Error(ctx, "Failed to check API key availability", err, map[string]interface{}{
268
            "user_id":  userID,
269
            "provider": provider,
270
        })
271
        HandleAppError(c, contextutils.WrapError(err, "failed to check API key availability"))
272
        return
273
    }
274

275
2x
    c.JSON(http.StatusOK, gin.H{"has_api_key": hasAPIKey})
276
}
277

278
// GetLearningPreferences retrieves user learning preferences
279
5x
func (h *SettingsHandler) GetLearningPreferences(c *gin.Context) {
280
5x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_learning_preferences")
281
5x
    defer observability.FinishSpan(span, nil)
282
5x
    session := sessions.Default(c)
283
5x
    userID, ok := session.Get(middleware.UserIDKey).(int)
284
5x
    if !ok {
285
        HandleAppError(c, contextutils.ErrUnauthorized)
286
        return
287
    }
288

289
5x
    preferences, err := h.learningService.GetUserLearningPreferences(ctx, userID)
290
5x
    if err != nil {
291
1x
        h.logger.Error(ctx, "Failed to get learning preferences", err, map[string]interface{}{
292
1x
            "user_id": userID,
293
1x
        })
294
1x
        HandleAppError(c, contextutils.WrapError(err, "failed to get learning preferences"))
295
1x
        return
296
1x
    }
297

298
    // Convert backend model to API schema
299
3x
    apiPreferences := convertLearningPreferencesToAPI(preferences)
300
3x
    c.JSON(http.StatusOK, apiPreferences)
301
}
302

303
// UpdateLearningPreferences updates user learning preferences
304
8x
func (h *SettingsHandler) UpdateLearningPreferences(c *gin.Context) {
305
8x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "update_learning_preferences")
306
8x
    defer observability.FinishSpan(span, nil)
307
8x
    session := sessions.Default(c)
308
8x
    userID, ok := session.Get(middleware.UserIDKey).(int)
309
8x
    if !ok {
310
        HandleAppError(c, contextutils.ErrUnauthorized)
311
        return
312
    }
313

314
8x
    var req models.UserLearningPreferences
315
8x
    if err := c.ShouldBindJSON(&req); err != nil {
316
3x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
317
3x
            contextutils.ErrorCodeInvalidInput,
318
3x
            contextutils.SeverityWarn,
319
3x
            "Invalid request body",
320
3x
            "",
321
3x
            err,
322
3x
        ))
323
3x
        return
324
3x
    }
325

326
    // Set the user ID
327
5x
    req.UserID = userID
328
5x

329
5x
    // Set span attributes for updated preferences
330
5x
    span.SetAttributes(
331
5x
        attribute.Bool("learning.focus_on_weak_areas", req.FocusOnWeakAreas),
332
5x
        attribute.Bool("learning.include_review_questions", req.IncludeReviewQuestions),
333
5x
        attribute.Float64("learning.fresh_question_ratio", req.FreshQuestionRatio),
334
5x
        attribute.Float64("learning.known_question_penalty", req.KnownQuestionPenalty),
335
5x
        attribute.Int("learning.review_interval_days", req.ReviewIntervalDays),
336
5x
        attribute.Float64("learning.weak_area_boost", req.WeakAreaBoost),
337
5x
    )
338
5x

339
5x
    // Update preferences in database
340
5x
    updatedPrefs, err := h.learningService.UpdateUserLearningPreferences(ctx, userID, &req)
341
5x
    if err != nil {
342
1x
        h.logger.Error(ctx, "Failed to update learning preferences", err, map[string]interface{}{
343
1x
            "user_id": userID,
344
1x
        })
345
1x
        HandleAppError(c, contextutils.WrapError(err, "failed to update learning preferences"))
346
1x
        return
347
1x
    }
348

349
    // Convert backend model to API schema and return
350
3x
    apiPreferences := convertLearningPreferencesToAPI(updatedPrefs)
351
3x
    c.JSON(http.StatusOK, apiPreferences)
352
}
353

354
// SendTestEmail sends a test email to the current user
355
1x
func (h *SettingsHandler) SendTestEmail(c *gin.Context) {
356
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "send_test_email")
357
1x
    defer observability.FinishSpan(span, nil)
358
1x

359
1x
    session := sessions.Default(c)
360
1x
    userID, ok := session.Get(middleware.UserIDKey).(int)
361
1x
    if !ok {
362
        HandleAppError(c, contextutils.ErrUnauthorized)
363
        return
364
    }
365

366
    // Get the current user
367
1x
    user, err := h.userService.GetUserByID(ctx, userID)
368
1x
    if err != nil {
369
        h.logger.Error(ctx, "Failed to get user for test email", err, map[string]interface{}{
370
            "user_id": userID,
371
        })
372
        HandleAppError(c, contextutils.WrapError(err, "failed to get user information"))
373
        return
374
    }
375

376
    // Check if user has an email address
377
1x
    if !user.Email.Valid || user.Email.String == "" {
378
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
379
1x
        return
380
1x
    }
381

382
    // Check if email service is enabled
383
    if !h.emailService.IsEnabled() {
384
        HandleAppError(c, contextutils.ErrServiceUnavailable)
385
        return
386
    }
387

388
    // Send test email
389
    err = h.emailService.SendEmail(ctx, user.Email.String, "Test Email from Quiz App", "test_email", map[string]interface{}{
390
        "Username": user.Username,
391
        "TestTime": "now",
392
        "Message":  "This is a test email to verify your email settings are working correctly.",
393
    })
394
    if err != nil {
395
        h.logger.Error(ctx, "Failed to send test email", err, map[string]interface{}{
396
            "user_id": userID,
397
            "email":   user.Email.String,
398
        })
399
        HandleAppError(c, contextutils.WrapError(err, "failed to send test email"))
400
        return
401
    }
402

403
    h.logger.Info(ctx, "Test email sent successfully", map[string]interface{}{
404
        "user_id": userID,
405
        "email":   user.Email.String,
406
    })
407

408
    c.JSON(http.StatusOK, api.SuccessResponse{Success: true, Message: stringPtr("Test email sent successfully")})
409
}
410


			
quizapp internal handlers worker_admin_handler.go
30.6%
Statements
15/49
1
//go:build integration
2
// +build integration
3

4
package handlers
5

6
import (
7
    "context"
8
    "encoding/json"
9
    "strings"
10

11
    "quizapp/internal/config"
12
    "quizapp/internal/models"
13
    "quizapp/internal/observability"
14
    "quizapp/internal/services"
15
    contextutils "quizapp/internal/utils"
16
)
17

18
// MockAIService implements AIServiceInterface for testing
19
type MockAIService struct {
20
    realService *services.AIService
21
}
22

23
2x
func NewMockAIService(cfg *config.Config, logger *observability.Logger) *MockAIService {
24
2x
    return &MockAIService{
25
2x
        realService: services.NewAIService(cfg, logger),
26
2x
    }
27
2x
}
28

29
// TestConnection returns a mock response for AI connection tests
30
1x
func (m *MockAIService) TestConnection(ctx context.Context, provider, model, apiKey string) error {
31
1x
    // For testing purposes, return success for valid-looking inputs
32
1x
    if provider != "" && model != "" {
33
1x
        // If it's a test API key, return an error to simulate failure
34
1x
        if strings.Contains(apiKey, "test") || apiKey == "" {
35
1x
            return contextutils.ErrorWithContextf("invalid API key")
36
1x
        }
37
        return nil
38
    }
39
    return contextutils.ErrorWithContextf("missing provider or model")
40
}
41

42
// CallWithPrompt returns a mock response for AI fix requests, otherwise delegates to real service
43
2x
func (m *MockAIService) CallWithPrompt(ctx context.Context, userConfig *services.UserAIConfig, prompt, grammar string) (string, error) {
44
2x
    // Check if this is an AI fix request by looking for fix-related keywords in the prompt
45
2x
    if strings.Contains(prompt, "fix") || strings.Contains(prompt, "Fix") ||
46
2x
        strings.Contains(prompt, "problematic") || strings.Contains(prompt, "report") {
47
2x
        // Return a mock AI fix response
48
2x
        mockResponse := map[string]interface{}{
49
2x
            "content": map[string]interface{}{
50
2x
                "question":       "What is the capital of France?",
51
2x
                "options":        []string{"Paris", "London", "Berlin", "Madrid"},
52
2x
                "correct_answer": 0,
53
2x
                "explanation":    "Paris is the capital and largest city of France.",
54
2x
            },
55
2x
            "correct_answer": 0,
56
2x
            "explanation":    "Paris is the capital and largest city of France.",
57
2x
            "change_reason":  "Fixed grammar and improved clarity of the question.",
58
2x
        }
59
2x

60
2x
        responseJSON, err := json.Marshal(mockResponse)
61
2x
        if err != nil {
62
            return "", err
63
        }
64
2x
        return string(responseJSON), nil
65
    }
66

67
    // For non-fix requests, delegate to the real service
68
    if m.realService != nil {
69
        return m.realService.CallWithPrompt(ctx, userConfig, prompt, grammar)
70
    }
71

72
    // Fallback response
73
    return `{"response": "Mock AI response"}`, nil
74
}
75

76
// Implement other required methods by delegating to real service or returning defaults
77
func (m *MockAIService) GenerateQuestion(ctx context.Context, userConfig *services.UserAIConfig, req *models.AIQuestionGenRequest) (*models.Question, error) {
78
    if m.realService != nil {
79
        return m.realService.GenerateQuestion(ctx, userConfig, req)
80
    }
81
    return nil, contextutils.ErrorWithContextf("GenerateQuestion not implemented in mock")
82
}
83

84
func (m *MockAIService) GenerateQuestions(ctx context.Context, userConfig *services.UserAIConfig, req *models.AIQuestionGenRequest) ([]*models.Question, error) {
85
    if m.realService != nil {
86
        return m.realService.GenerateQuestions(ctx, userConfig, req)
87
    }
88
    return nil, contextutils.ErrorWithContextf("GenerateQuestions not implemented in mock")
89
}
90

91
func (m *MockAIService) GenerateQuestionsStream(ctx context.Context, userConfig *services.UserAIConfig, req *models.AIQuestionGenRequest, progress chan<- *models.Question, variety *services.VarietyElements) error {
92
    if m.realService != nil {
93
        return m.realService.GenerateQuestionsStream(ctx, userConfig, req, progress, variety)
94
    }
95
    return contextutils.ErrorWithContextf("GenerateQuestionsStream not implemented in mock")
96
}
97

98
func (m *MockAIService) GenerateChatResponse(ctx context.Context, userConfig *services.UserAIConfig, req *models.AIChatRequest) (string, error) {
99
    if m.realService != nil {
100
        return m.realService.GenerateChatResponse(ctx, userConfig, req)
101
    }
102
    return "Mock chat response", nil
103
}
104

105
func (m *MockAIService) GenerateChatResponseStream(ctx context.Context, userConfig *services.UserAIConfig, req *models.AIChatRequest, chunks chan<- string) error {
106
    if m.realService != nil {
107
        return m.realService.GenerateChatResponseStream(ctx, userConfig, req, chunks)
108
    }
109
    select {
110
    case chunks <- "Mock streaming response":
111
    default:
112
    }
113
    return nil
114
}
115

116
1x
func (m *MockAIService) GetConcurrencyStats() services.ConcurrencyStats {
117
1x
    if m.realService != nil {
118
1x
        return m.realService.GetConcurrencyStats()
119
1x
    }
120
    return services.ConcurrencyStats{}
121
}
122

123
func (m *MockAIService) GetQuestionBatchSize(provider string) int {
124
    if m.realService != nil {
125
        return m.realService.GetQuestionBatchSize(provider)
126
    }
127
    return 1
128
}
129

130
func (m *MockAIService) VarietyService() *services.VarietyService {
131
    if m.realService != nil {
132
        return m.realService.VarietyService()
133
    }
134
    return nil
135
}
136

137
4x
func (m *MockAIService) TemplateManager() *services.AITemplateManager {
138
4x
    if m.realService != nil {
139
4x
        return m.realService.TemplateManager()
140
4x
    }
141
    return nil
142
}
143

144
2x
func (m *MockAIService) SupportsGrammarField(provider string) bool {
145
2x
    if m.realService != nil {
146
2x
        return m.realService.SupportsGrammarField(provider)
147
2x
    }
148
    return false
149
}
150

151
func (m *MockAIService) Shutdown(ctx context.Context) error {
152
    if m.realService != nil {
153
        return m.realService.Shutdown(ctx)
154
    }
155
    return nil
156
}
157


			
quizapp internal handlers worker_admin_handler.go
44.8%
Statements
181/404
1
package handlers
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "fmt"
8
    "html/template"
9
    "net/http"
10
    "strconv"
11
    "strings"
12
    "time"
13

14
    "quizapp/internal/api"
15
    "quizapp/internal/config"
16
    "quizapp/internal/models"
17
    "quizapp/internal/observability"
18
    "quizapp/internal/services"
19
    contextutils "quizapp/internal/utils"
20

21
    "github.com/gin-gonic/gin"
22
)
23

24
// UserAdminHandler handles user management operations
25
type UserAdminHandler struct {
26
    userService services.UserServiceInterface
27
    cfg         *config.Config
28
    templates   *template.Template
29
    logger      *observability.Logger
30
}
31

32
// NewUserAdminHandler creates a new UserAdminHandler instance
33
11x
func NewUserAdminHandler(userService services.UserServiceInterface, cfg *config.Config, logger *observability.Logger) *UserAdminHandler {
34
11x
    return &UserAdminHandler{
35
11x
        userService: userService,
36
11x
        cfg:         cfg,
37
11x
        templates:   nil,
38
11x
        logger:      logger,
39
11x
    }
40
11x
}
41

42
// UserCreateRequest represents a request to create a new user
43
// Using the generated type from api package for automatic validation
44
type UserCreateRequest = api.UserCreateRequest
45

46
// UserUpdateRequest represents a request to update user profile
47
// Using the generated type from api package for automatic validation
48
type UserUpdateRequest = api.UserUpdateRequest
49

50
// PasswordResetRequest represents a request to reset user password
51
// Using the generated type from api package for automatic validation
52
type PasswordResetRequest = api.PasswordResetRequest
53

54
// ProfileResponse represents user profile data
55
type ProfileResponse struct {
56
    ID                int           `json:"id"`
57
    Username          string        `json:"username"`
58
    Email             *string       `json:"email"`
59
    Timezone          *string       `json:"timezone"`
60
    LastActive        *time.Time    `json:"last_active"`
61
    PreferredLanguage *string       `json:"preferred_language"`
62
    CurrentLevel      *string       `json:"current_level"`
63
    CreatedAt         time.Time     `json:"created_at"`
64
    UpdatedAt         time.Time     `json:"updated_at"`
65
    AIEnabled         bool          `json:"ai_enabled"`
66
    AIProvider        *string       `json:"ai_provider"`
67
    AIModel           *string       `json:"ai_model"`
68
    Roles             []models.Role `json:"roles,omitempty"`
69
    IsPaused          bool          `json:"is_paused"`
70
}
71

72
// GetAllUsers handles GET /userz - list all users (admin only) - JSON API
73
1x
func (h *UserAdminHandler) GetAllUsers(c *gin.Context) {
74
1x
    users, err := h.userService.GetAllUsers(c.Request.Context())
75
1x
    if err != nil {
76
        h.logger.Error(c.Request.Context(), "Error retrieving users", err, nil)
77
        HandleAppError(c, contextutils.WrapError(err, "failed to retrieve users"))
78
        return
79
    }
80

81
    // Convert to response format
82
1x
    var userResponses []ProfileResponse
83
1x
    for _, user := range users {
84
3x
        userResponses = append(userResponses, h.convertUserToProfileResponse(c.Request.Context(), &user))
85
3x
    }
86

87
1x
    c.JSON(http.StatusOK, gin.H{"users": userResponses})
88
}
89

90
// GetUsersPaginated handles GET /userz/paginated - list users with pagination (admin only)
91
func (h *UserAdminHandler) GetUsersPaginated(c *gin.Context) {
92
    // Parse pagination parameters
93
    page, pageSize := h.parsePagination(c)
94

95
    // Parse filters
96
    search := c.Query("search")
97
    language := c.Query("language")
98
    level := c.Query("level")
99
    aiProvider := c.Query("ai_provider")
100
    aiModel := c.Query("ai_model")
101
    aiEnabled := c.Query("ai_enabled")
102
    active := c.Query("active")
103

104
    // Get paginated users from service
105
    var users []models.User
106
    var total int
107
    var err error
108
    users, total, err = h.userService.GetUsersPaginated(
109
        c.Request.Context(),
110
        page,
111
        pageSize,
112
        search,
113
        language,
114
        level,
115
        aiProvider,
116
        aiModel,
117
        aiEnabled,
118
        active,
119
    )
120
    if err != nil {
121
        h.logger.Error(c.Request.Context(), "Error retrieving paginated users", err, map[string]interface{}{
122
            "page":      page,
123
            "page_size": pageSize,
124
            "search":    search,
125
        })
126
        HandleAppError(c, contextutils.WrapError(err, "failed to retrieve users"))
127
        return
128
    }
129

130
    // Convert to response format
131
    var userResponses []ProfileResponse
132
    for _, user := range users {
133
        userResponses = append(userResponses, h.convertUserToProfileResponse(c.Request.Context(), &user))
134
    }
135

136
    // Calculate pagination info
137
    totalPages := (total + pageSize - 1) / pageSize
138

139
    c.JSON(http.StatusOK, gin.H{
140
        "users": userResponses,
141
        "pagination": gin.H{
142
            "page":        page,
143
            "page_size":   pageSize,
144
            "total":       total,
145
            "total_pages": totalPages,
146
        },
147
    })
148
}
149

150
// parsePagination parses pagination parameters from the request
151
func (h *UserAdminHandler) parsePagination(c *gin.Context) (page, pageSize int) {
152
    page = 1
153
    pageSize = 20
154

155
    if pageStr := c.Query("page"); pageStr != "" {
156
        if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
157
            page = p
158
        }
159
    }
160

161
    if pageSizeStr := c.Query("page_size"); pageSizeStr != "" {
162
        if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 100 {
163
            pageSize = ps
164
        }
165
    }
166

167
    return page, pageSize
168
}
169

170
// CreateUser handles POST /userz - create new user (admin only)
171
4x
func (h *UserAdminHandler) CreateUser(c *gin.Context) {
172
4x
    var req UserCreateRequest
173
4x
    if err := c.ShouldBindJSON(&req); err != nil {
174
        HandleAppError(c, contextutils.NewAppErrorWithCause(
175
            contextutils.ErrorCodeInvalidInput,
176
            contextutils.SeverityWarn,
177
            "Invalid request data",
178
            "",
179
            err,
180
        ))
181
        return
182
    }
183

184
    // Validate required fields
185
4x
    if req.Username == "" {
186
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
187
1x
        return
188
1x
    }
189
3x
    if req.Password == "" {
190
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
191
1x
        return
192
1x
    }
193

194
    // Extract values from generated types
195
2x
    timezone := "UTC"
196
2x
    if req.Timezone != nil && *req.Timezone != "" {
197
1x
        timezone = *req.Timezone
198
1x
        // Validate timezone if provided
199
1x
        if !h.isValidTimezone(timezone) {
200
            HandleAppError(c, contextutils.ErrInvalidFormat)
201
            return
202
        }
203
    }
204

205
2x
    preferredLanguage := "italian"
206
2x
    if req.PreferredLanguage != nil && *req.PreferredLanguage != "" {
207
1x
        preferredLanguage = *req.PreferredLanguage
208
1x
    }
209

210
2x
    currentLevel := "A1"
211
2x
    if req.CurrentLevel != nil && *req.CurrentLevel != "" {
212
1x
        currentLevel = *req.CurrentLevel
213
1x
    }
214

215
2x
    email := ""
216
2x
    if req.Email != nil {
217
2x
        email = string(*req.Email)
218
2x
    }
219

220
    // Check if username already exists
221
2x
    existingUser, err := h.userService.GetUserByUsername(c.Request.Context(), req.Username)
222
2x
    if err != nil {
223
        h.logger.Error(c.Request.Context(), "Error checking existing username", err, nil)
224
        HandleAppError(c, contextutils.WrapError(err, "failed to check existing username"))
225
        return
226
    }
227
2x
    if existingUser != nil {
228
1x
        HandleAppError(c, contextutils.ErrRecordExists)
229
1x
        return
230
1x
    }
231

232
    // Check if email already exists (if provided)
233
1x
    if email != "" {
234
1x
        existingUser, err := h.userService.GetUserByEmail(c.Request.Context(), email)
235
1x
        if err != nil {
236
            h.logger.Error(c.Request.Context(), "Error checking existing email", err, nil)
237
            HandleAppError(c, contextutils.WrapError(err, "failed to check email uniqueness"))
238
            return
239
        }
240
1x
        if existingUser != nil {
241
            HandleAppError(c, contextutils.ErrRecordExists)
242
            return
243
        }
244
    }
245

246
    // Create user
247
1x
    user, err := h.userService.CreateUserWithEmailAndTimezone(
248
1x
        c.Request.Context(),
249
1x
        req.Username,
250
1x
        email,
251
1x
        timezone,
252
1x
        preferredLanguage,
253
1x
        currentLevel,
254
1x
    )
255
1x
    if err != nil {
256
        h.logger.Error(c.Request.Context(), "Error creating user", err, nil)
257
        HandleAppError(c, contextutils.WrapError(err, "failed to create user"))
258
        return
259
    }
260

261
    // Set password
262
1x
    err = h.userService.UpdateUserPassword(c.Request.Context(), user.ID, req.Password)
263
1x
    if err != nil {
264
        h.logger.Error(c.Request.Context(), "Error setting user password", err, nil)
265
        // Try to clean up the created user
266
        _ = h.userService.DeleteUser(c.Request.Context(), user.ID)
267
        HandleAppError(c, contextutils.WrapError(err, "failed to set user password"))
268
        return
269
    }
270

271
    // Return the created user profile
272
1x
    c.JSON(http.StatusCreated, gin.H{
273
1x
        "message": "User created successfully",
274
1x
        "user":    h.convertUserToProfileResponse(c.Request.Context(), user),
275
1x
    })
276
}
277

278
// UpdateUser handles PUT /userz/:id - update user details (admin or self)
279
1x
func (h *UserAdminHandler) UpdateUser(c *gin.Context) {
280
1x
    userIDStr := c.Param("id")
281
1x
    userID, err := strconv.Atoi(userIDStr)
282
1x
    if err != nil {
283
        HandleAppError(c, contextutils.ErrInvalidFormat)
284
        return
285
    }
286

287
    // Check if user exists
288
1x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID)
289
1x
    if err != nil {
290
        h.logger.Error(c.Request.Context(), "Error retrieving user", err, nil)
291
        HandleAppError(c, contextutils.WrapError(err, "database error"))
292
        return
293
    }
294
1x
    if user == nil {
295
        HandleAppError(c, contextutils.ErrRecordNotFound)
296
        return
297
    }
298

299
    // Check authorization (admin or self) - skip for direct routes (testing)
300
1x
    if currentUserID, err := GetCurrentUserID(c); err == nil {
301
1x
        if err := RequireSelfOrAdmin(c.Request.Context(), h.userService, currentUserID, userID); err != nil {
302
            if contextutils.IsError(err, contextutils.ErrForbidden) {
303
                HandleAppError(c, contextutils.ErrForbidden)
304
                return
305
            }
306
            h.logger.Error(c.Request.Context(), "Error checking authorization", err, nil)
307
            HandleAppError(c, contextutils.WrapError(err, "failed to check authorization"))
308
            return
309
        }
310
    }
311

312
1x
    var req UserUpdateRequest
313
1x
    if err := c.ShouldBindJSON(&req); err != nil {
314
        HandleAppError(c, contextutils.NewAppErrorWithCause(
315
            contextutils.ErrorCodeInvalidInput,
316
            contextutils.SeverityWarn,
317
            "Invalid request data",
318
            "",
319
            err,
320
        ))
321
        return
322
    }
323

324
    // Validate timezone if provided
325
1x
    if req.Timezone != nil && *req.Timezone != "" && !h.isValidTimezone(*req.Timezone) {
326
        HandleAppError(c, contextutils.ErrInvalidFormat)
327
        return
328
    }
329

330
    // Use existing values if not provided in request
331
1x
    username := user.Username
332
1x
    if req.Username != nil && *req.Username != "" {
333
1x
        username = *req.Username
334
1x
    }
335

336
1x
    email := ""
337
1x
    if user.Email.Valid {
338
1x
        email = user.Email.String
339
1x
    }
340
1x
    if req.Email != nil {
341
1x
        email = string(*req.Email)
342
1x
    }
343

344
1x
    timezone := ""
345
1x
    if user.Timezone.Valid {
346
1x
        timezone = user.Timezone.String
347
1x
    }
348
1x
    if req.Timezone != nil && *req.Timezone != "" {
349
1x
        timezone = *req.Timezone
350
1x
    }
351

352
1x
    preferredLanguage := ""
353
1x
    if user.PreferredLanguage.Valid {
354
1x
        preferredLanguage = user.PreferredLanguage.String
355
1x
    }
356
1x
    if req.PreferredLanguage != nil && *req.PreferredLanguage != "" {
357
        preferredLanguage = *req.PreferredLanguage
358
    }
359

360
1x
    currentLevel := ""
361
1x
    if user.CurrentLevel.Valid {
362
1x
        currentLevel = user.CurrentLevel.String
363
1x
    }
364
1x
    if req.CurrentLevel != nil && *req.CurrentLevel != "" {
365
        currentLevel = *req.CurrentLevel
366
    }
367

368
    // Check if new username already exists (if changed)
369
1x
    if username != user.Username {
370
1x
        existingUser, err := h.userService.GetUserByUsername(c.Request.Context(), username)
371
1x
        if err != nil {
372
            h.logger.Error(c.Request.Context(), "Error checking existing username", err, nil)
373
            HandleAppError(c, contextutils.WrapError(err, "failed to check username uniqueness"))
374
            return
375
        }
376
1x
        if existingUser != nil {
377
            HandleAppError(c, contextutils.ErrRecordExists)
378
            return
379
        }
380
    }
381

382
    // Check if new email already exists (if changed)
383
1x
    if email != "" && user.Email.Valid && email != user.Email.String {
384
1x
        existingUser, err := h.userService.GetUserByEmail(c.Request.Context(), email)
385
1x
        if err != nil {
386
            h.logger.Error(c.Request.Context(), "Error checking existing email", err, nil)
387
            HandleAppError(c, contextutils.WrapError(err, "failed to check email uniqueness"))
388
            return
389
        }
390
1x
        if existingUser != nil {
391
            HandleAppError(c, contextutils.ErrRecordExists)
392
            return
393
        }
394
    }
395

396
    // Update user profile
397
1x
    err = h.userService.UpdateUserProfile(c.Request.Context(), userID, username, email, timezone)
398
1x
    if err != nil {
399
        h.logger.Error(c.Request.Context(), "Error updating user profile", err, nil)
400

401
        // Check if the error is due to user not found
402
        if errors.Is(err, contextutils.ErrRecordNotFound) {
403
            HandleAppError(c, contextutils.ErrRecordNotFound)
404
            return
405
        }
406

407
        HandleAppError(c, contextutils.WrapError(err, "failed to update user profile"))
408
        return
409
    }
410

411
    // Handle AI settings update if provided
412
1x
    needsAIUpdate := req.AiEnabled != nil || (req.AiProvider != nil && *req.AiProvider != "") || (req.AiModel != nil && *req.AiModel != "") || (req.ApiKey != nil && *req.ApiKey != "")
413
1x
    if needsAIUpdate {
414
        // Prepare AI settings
415
        aiSettings := &models.UserSettings{
416
            Language:  preferredLanguage,
417
            Level:     currentLevel,
418
            AIEnabled: req.AiEnabled != nil && *req.AiEnabled,
419
        }
420

421
        // Set AI provider and model
422
        if req.AiProvider != nil && *req.AiProvider != "" {
423
            aiSettings.AIProvider = *req.AiProvider
424
        } else if user.AIProvider.Valid {
425
            aiSettings.AIProvider = user.AIProvider.String
426
        }
427

428
        if req.AiModel != nil && *req.AiModel != "" {
429
            aiSettings.AIModel = *req.AiModel
430
        } else if user.AIModel.Valid {
431
            aiSettings.AIModel = user.AIModel.String
432
        }
433

434
        // Set API key if provided
435
        if req.ApiKey != nil && *req.ApiKey != "" {
436
            aiSettings.AIAPIKey = *req.ApiKey
437
        }
438

439
        // Update AI settings
440
        err = h.userService.UpdateUserSettings(c.Request.Context(), userID, aiSettings)
441
        if err != nil {
442
            h.logger.Error(c.Request.Context(), "Error updating user AI settings", err, nil)
443

444
            // Check if the error is due to user not found
445
            if errors.Is(err, contextutils.ErrRecordNotFound) {
446
                HandleAppError(c, contextutils.ErrRecordNotFound)
447
                return
448
            }
449

450
            HandleAppError(c, contextutils.WrapError(err, "failed to update AI settings"))
451
            return
452
        }
453
    }
454

455
    // Handle role updates if provided
456
1x
    if req.SelectedRoles != nil {
457
        // Get current user roles
458
        currentRoles, err := h.userService.GetUserRoles(c.Request.Context(), userID)
459
        if err != nil {
460
            h.logger.Error(c.Request.Context(), "Error getting current user roles", err, nil)
461
            HandleAppError(c, contextutils.WrapError(err, "failed to get current user roles"))
462
            return
463
        }
464

465
        // Get all available roles
466
        allRoles, err := h.userService.GetAllRoles(c.Request.Context())
467
        if err != nil {
468
            h.logger.Error(c.Request.Context(), "Error getting all roles", err, nil)
469
            HandleAppError(c, contextutils.WrapError(err, "failed to get available roles"))
470
            return
471
        }
472

473
        // Create maps for efficient lookup
474
        currentRoleNames := make(map[string]bool)
475
        for _, role := range currentRoles {
476
            currentRoleNames[role.Name] = true
477
        }
478

479
        requestedRoleNames := make(map[string]bool)
480
        for _, roleName := range *req.SelectedRoles {
481
            requestedRoleNames[roleName] = true
482
        }
483

484
        // Find roles to add and remove
485
        for _, roleName := range *req.SelectedRoles {
486
            if !currentRoleNames[roleName] {
487
                // Find role by name
488
                var roleToAdd *models.Role
489
                for _, role := range allRoles {
490
                    if role.Name == roleName {
491
                        roleToAdd = &role
492
                        break
493
                    }
494
                }
495
                if roleToAdd != nil {
496
                    err = h.userService.AssignRole(c.Request.Context(), userID, roleToAdd.ID)
497
                    if err != nil {
498
                        h.logger.Error(c.Request.Context(), "Error assigning role to user", err, map[string]interface{}{
499
                            "user_id":   userID,
500
                            "role_id":   roleToAdd.ID,
501
                            "role_name": roleName,
502
                        })
503
                        HandleAppError(c, contextutils.WrapError(err, "failed to assign role"))
504
                        return
505
                    }
506
                }
507
            }
508
        }
509

510
        // Remove roles that are no longer selected
511
        for _, role := range currentRoles {
512
            if !requestedRoleNames[role.Name] {
513
                err = h.userService.RemoveRole(c.Request.Context(), userID, role.ID)
514
                if err != nil {
515
                    h.logger.Error(c.Request.Context(), "Error removing role from user", err, map[string]interface{}{
516
                        "user_id":   userID,
517
                        "role_id":   role.ID,
518
                        "role_name": role.Name,
519
                    })
520
                    HandleAppError(c, contextutils.WrapError(err, "failed to remove role"))
521
                    return
522
                }
523
            }
524
        }
525
    }
526

527
    // Get updated user
528
1x
    updatedUser, err := h.userService.GetUserByID(c.Request.Context(), userID)
529
1x
    if err != nil {
530
        h.logger.Error(c.Request.Context(), "Error retrieving updated user", err, nil)
531
        HandleAppError(c, contextutils.WrapError(err, "failed to retrieve updated user"))
532
        return
533
    }
534

535
1x
    c.JSON(http.StatusOK, gin.H{
536
1x
        "message": "User updated successfully",
537
1x
        "user":    h.convertUserToProfileResponse(c.Request.Context(), updatedUser),
538
1x
    })
539
}
540

541
// DeleteUser handles DELETE /userz/:id - delete user (admin only)
542
1x
func (h *UserAdminHandler) DeleteUser(c *gin.Context) {
543
1x
    userIDStr := c.Param("id")
544
1x
    userID, err := strconv.Atoi(userIDStr)
545
1x
    if err != nil {
546
        HandleAppError(c, contextutils.ErrInvalidFormat)
547
        return
548
    }
549

550
    // Check if user exists
551
1x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID)
552
1x
    if err != nil {
553
        h.logger.Error(c.Request.Context(), "Error retrieving user", err, nil)
554
        HandleAppError(c, contextutils.WrapError(err, "database error"))
555
        return
556
    }
557
1x
    if user == nil {
558
        HandleAppError(c, contextutils.ErrRecordNotFound)
559
        return
560
    }
561

562
    // Delete user
563
1x
    err = h.userService.DeleteUser(c.Request.Context(), userID)
564
1x
    if err != nil {
565
        h.logger.Error(c.Request.Context(), "Error deleting user", err, nil)
566
        HandleAppError(c, contextutils.WrapError(err, "failed to delete user"))
567
        return
568
    }
569

570
1x
    c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"})
571
}
572

573
// ResetUserPassword handles POST /userz/:id/reset-password - reset user password (admin only)
574
1x
func (h *UserAdminHandler) ResetUserPassword(c *gin.Context) {
575
1x
    userIDStr := c.Param("id")
576
1x
    userID, err := strconv.Atoi(userIDStr)
577
1x
    if err != nil {
578
        HandleAppError(c, contextutils.ErrInvalidFormat)
579
        return
580
    }
581

582
    // Check if user exists
583
1x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID)
584
1x
    if err != nil {
585
        h.logger.Error(c.Request.Context(), "Error retrieving user", err, map[string]interface{}{"user_id": userID})
586
        HandleAppError(c, contextutils.WrapError(err, "database error"))
587
        return
588
    }
589
1x
    if user == nil {
590
        h.logger.Warn(c.Request.Context(), "User not found for password reset", map[string]interface{}{"user_id": userID})
591
        HandleAppError(c, contextutils.ErrRecordNotFound)
592
        return
593
    }
594

595
1x
    var req PasswordResetRequest
596
1x
    if err := c.ShouldBindJSON(&req); err != nil {
597
        h.logger.Error(c.Request.Context(), "Invalid request data for password reset", err, map[string]interface{}{"user_id": userID})
598
        HandleAppError(c, contextutils.NewAppErrorWithCause(
599
            contextutils.ErrorCodeInvalidInput,
600
            contextutils.SeverityWarn,
601
            "Invalid request data",
602
            "",
603
            err,
604
        ))
605
        return
606
    }
607

608
    // Validate password
609
1x
    if req.NewPassword == "" {
610
        HandleAppError(c, contextutils.ErrMissingRequired)
611
        return
612
    }
613

614
    // Update password
615
1x
    err = h.userService.UpdateUserPassword(c.Request.Context(), userID, req.NewPassword)
616
1x
    if err != nil {
617
        h.logger.Error(c.Request.Context(), "Error updating user password", err, map[string]interface{}{"user_id": userID})
618
        HandleAppError(c, contextutils.WrapError(err, "failed to update password"))
619
        return
620
    }
621

622
1x
    h.logger.Info(c.Request.Context(), "Password reset successful", map[string]interface{}{"user_id": userID, "username": user.Username})
623
1x
    c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"})
624
}
625

626
// UpdateCurrentUserProfile handles PUT /userz/profile - update current user profile
627
2x
func (h *UserAdminHandler) UpdateCurrentUserProfile(c *gin.Context) {
628
2x
    // Get user ID from context/session
629
2x
    userID, err := GetCurrentUserID(c)
630
2x
    if err != nil {
631
        HandleAppError(c, contextutils.ErrUnauthorized)
632
        return
633
    }
634

635
2x
    var req UserUpdateRequest
636
2x
    if err := c.ShouldBindJSON(&req); err != nil {
637
        HandleAppError(c, contextutils.NewAppErrorWithCause(
638
            contextutils.ErrorCodeInvalidInput,
639
            contextutils.SeverityWarn,
640
            "Invalid request data",
641
            "",
642
            err,
643
        ))
644
        return
645
    }
646

647
    // Validate timezone if provided
648
2x
    if req.Timezone != nil && *req.Timezone != "" && !h.isValidTimezone(*req.Timezone) {
649
        HandleAppError(c, contextutils.ErrInvalidFormat)
650
        return
651
    }
652

653
    // Email validation is handled automatically by openapi_types.Email
654

655
    // Get current user
656
2x
    user, err := h.userService.GetUserByID(c.Request.Context(), userID)
657
2x
    if err != nil {
658
        h.logger.Error(c.Request.Context(), "Error retrieving user", err, nil)
659
        HandleAppError(c, contextutils.WrapError(err, "database error"))
660
        return
661
    }
662
2x
    if user == nil {
663
        HandleAppError(c, contextutils.ErrRecordNotFound)
664
        return
665
    }
666

667
    // Check authorization (self-only for this endpoint)
668
2x
    if err := RequireSelfOrAdmin(c.Request.Context(), h.userService, userID, userID); err != nil {
669
        if contextutils.IsError(err, contextutils.ErrForbidden) {
670
            HandleAppError(c, contextutils.ErrForbidden)
671
            return
672
        }
673
        h.logger.Error(c.Request.Context(), "Error checking authorization", err, nil)
674
        HandleAppError(c, contextutils.WrapError(err, "failed to check authorization"))
675
        return
676
    }
677

678
    // Use existing values if not provided in request
679
2x
    username := user.Username
680
2x
    if req.Username != nil && *req.Username != "" {
681
2x
        username = *req.Username
682
2x
    }
683

684
2x
    email := ""
685
2x
    if user.Email.Valid {
686
        email = user.Email.String
687
    }
688
2x
    if req.Email != nil {
689
1x
        email = string(*req.Email)
690
1x
    }
691

692
2x
    timezone := ""
693
2x
    if user.Timezone.Valid {
694
2x
        timezone = user.Timezone.String
695
2x
    }
696
2x
    if req.Timezone != nil && *req.Timezone != "" {
697
2x
        timezone = *req.Timezone
698
2x
    }
699

700
    // Check if new username already exists (if changed)
701
2x
    if username != user.Username {
702
        existingUser, err := h.userService.GetUserByUsername(c.Request.Context(), username)
703
        if err != nil {
704
            h.logger.Error(c.Request.Context(), "Error checking existing username", err, nil)
705
            HandleAppError(c, contextutils.WrapError(err, "failed to check username uniqueness"))
706
            return
707
        }
708
        if existingUser != nil {
709
            HandleAppError(c, contextutils.ErrRecordExists)
710
            return
711
        }
712
    }
713

714
    // Check if new email already exists (if changed)
715
2x
    if email != "" && user.Email.Valid && email != user.Email.String {
716
        existingUser, err := h.userService.GetUserByEmail(c.Request.Context(), email)
717
        if err != nil {
718
            h.logger.Error(c.Request.Context(), "Error checking existing email", err, nil)
719
            HandleAppError(c, contextutils.WrapError(err, "failed to check email uniqueness"))
720
            return
721
        }
722
        if existingUser != nil {
723
            HandleAppError(c, contextutils.ErrRecordExists)
724
            return
725
        }
726
    }
727

728
    // Use existing AI values if not provided in request
729
2x
    preferredLanguage := ""
730
2x
    if user.PreferredLanguage.Valid {
731
2x
        preferredLanguage = user.PreferredLanguage.String
732
2x
    }
733
2x
    if req.PreferredLanguage != nil && *req.PreferredLanguage != "" {
734
2x
        preferredLanguage = *req.PreferredLanguage
735
2x
    }
736

737
2x
    currentLevel := ""
738
2x
    if user.CurrentLevel.Valid {
739
2x
        currentLevel = user.CurrentLevel.String
740
2x
    }
741
2x
    if req.CurrentLevel != nil && *req.CurrentLevel != "" {
742
2x
        currentLevel = *req.CurrentLevel
743
2x
    }
744

745
    // Update user profile
746
2x
    err = h.userService.UpdateUserProfile(c.Request.Context(), userID, username, email, timezone)
747
2x
    if err != nil {
748
        h.logger.Error(c.Request.Context(), "Error updating user profile", err, nil)
749
        HandleAppError(c, contextutils.WrapError(err, "failed to update user profile"))
750
        return
751
    }
752

753
    // Handle AI settings update if provided
754
2x
    needsAIUpdate := req.AiEnabled != nil || (req.AiProvider != nil && *req.AiProvider != "") || (req.AiModel != nil && *req.AiModel != "") || (req.PreferredLanguage != nil && *req.PreferredLanguage != "") || (req.CurrentLevel != nil && *req.CurrentLevel != "") || (req.ApiKey != nil && *req.ApiKey != "")
755
2x

756
2x
    if needsAIUpdate {
757
2x
        aiSettings := &models.UserSettings{
758
2x
            Language:  preferredLanguage,
759
2x
            Level:     currentLevel,
760
2x
            AIEnabled: req.AiEnabled != nil && *req.AiEnabled,
761
2x
        }
762
2x

763
2x
        if req.AiProvider != nil && *req.AiProvider != "" {
764
1x
            aiSettings.AIProvider = *req.AiProvider
765
1x
        } else if user.AIProvider.Valid {
766
            aiSettings.AIProvider = user.AIProvider.String
767
        }
768

769
2x
        if req.AiModel != nil && *req.AiModel != "" {
770
1x
            aiSettings.AIModel = *req.AiModel
771
1x
        } else if user.AIModel.Valid {
772
            aiSettings.AIModel = user.AIModel.String
773
        }
774

775
2x
        if req.ApiKey != nil && *req.ApiKey != "" {
776
1x
            aiSettings.AIAPIKey = *req.ApiKey
777
1x
        }
778

779
2x
        err = h.userService.UpdateUserSettings(c.Request.Context(), userID, aiSettings)
780
2x
        if err != nil {
781
            h.logger.Error(c.Request.Context(), "Error updating user AI settings", err, nil)
782
            HandleAppError(c, contextutils.WrapError(err, "failed to update AI settings"))
783
            return
784
        }
785
    }
786

787
    // Get updated user
788
2x
    updatedUser, err := h.userService.GetUserByID(c.Request.Context(), userID)
789
2x
    if err != nil {
790
        h.logger.Error(c.Request.Context(), "Error retrieving updated user", err, nil)
791
        HandleAppError(c, contextutils.WrapError(err, "failed to retrieve updated profile"))
792
        return
793
    }
794

795
2x
    c.JSON(http.StatusOK, gin.H{
796
2x
        "message": "Profile updated successfully",
797
2x
        "user":    h.convertUserToProfileResponse(c.Request.Context(), updatedUser),
798
2x
    })
799
}
800

801
// isUserPaused checks if a user is paused by checking the worker_settings table
802
7x
func (h *UserAdminHandler) isUserPaused(ctx context.Context, userID int) bool {
803
7x
    query := `SELECT setting_value FROM worker_settings WHERE setting_key = $1`
804
7x
    var value string
805
7x
    settingKey := fmt.Sprintf("user_pause_%d", userID)
806
7x

807
7x
    err := h.userService.GetDB().QueryRowContext(ctx, query, settingKey).Scan(&value)
808
7x
    if err != nil {
809
7x
        // If no setting exists, user is not paused
810
7x
        if errors.Is(err, sql.ErrNoRows) {
811
7x
            return false
812
7x
        }
813
        // Log error but don't fail - default to not paused
814
        h.logger.Warn(ctx, "Failed to check user pause status", map[string]interface{}{
815
            "user_id": userID,
816
            "error":   err.Error(),
817
        })
818
        return false
819
    }
820

821
    return value == "true"
822
}
823

824
// Helper functions
825

826
// convertUserToProfileResponse converts a User model to ProfileResponse
827
7x
func (h *UserAdminHandler) convertUserToProfileResponse(ctx context.Context, user *models.User) ProfileResponse {
828
7x
    // Get user roles
829
7x
    roles, err := h.userService.GetUserRoles(ctx, user.ID)
830
7x
    if err != nil {
831
        // Log error but don't fail the response
832
        h.logger.Warn(ctx, "Failed to get user roles", map[string]interface{}{
833
            "user_id": user.ID,
834
            "error":   err.Error(),
835
        })
836
        roles = []models.Role{}
837
    }
838

839
7x
    return ProfileResponse{
840
7x
        ID:                user.ID,
841
7x
        Username:          user.Username,
842
7x
        Email:             nullStringToPointer(user.Email),
843
7x
        Timezone:          nullStringToPointer(user.Timezone),
844
7x
        LastActive:        nullTimeToPointer(user.LastActive),
845
7x
        PreferredLanguage: nullStringToPointer(user.PreferredLanguage),
846
7x
        CurrentLevel:      nullStringToPointer(user.CurrentLevel),
847
7x
        CreatedAt:         user.CreatedAt,
848
7x
        UpdatedAt:         user.UpdatedAt,
849
7x
        AIEnabled:         user.AIEnabled.Valid && user.AIEnabled.Bool,
850
7x
        AIProvider:        nullStringToPointer(user.AIProvider),
851
7x
        AIModel:           nullStringToPointer(user.AIModel),
852
7x
        Roles:             roles,
853
7x
        IsPaused:          h.isUserPaused(ctx, user.ID),
854
7x
    }
855
}
856

857
// isValidTimezone checks if a timezone string is valid
858
4x
func (h *UserAdminHandler) isValidTimezone(tz string) bool {
859
4x
    // Common timezone validation - check if it can be loaded
860
4x
    _, err := time.LoadLocation(tz)
861
4x
    if err != nil {
862
        // Also allow UTC as fallback
863
        return strings.ToUpper(tz) == "UTC"
864
    }
865
4x
    return true
866
}
867

868
// Helper function to convert sql.NullString to *string (if not already available)
869
42x
func nullStringToPointer(ns sql.NullString) *string {
870
42x
    if ns.Valid {
871
31x
        return &ns.String
872
31x
    }
873
11x
    return nil
874
}
875

876
// Helper function to convert sql.NullTime to *time.Time (if not already available)
877
7x
func nullTimeToPointer(nt sql.NullTime) *time.Time {
878
7x
    if nt.Valid {
879
7x
        return &nt.Time
880
7x
    }
881
    return nil
882
}
883


			
quizapp internal handlers worker_admin_handler.go
43.6%
Statements
164/376
1
package handlers
2

3
import (
4
    "errors"
5
    "fmt"
6
    "html/template"
7
    "net/http"
8
    "strconv"
9
    "strings"
10
    "time"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/observability"
14
    "quizapp/internal/services"
15
    contextutils "quizapp/internal/utils"
16
    "quizapp/internal/worker"
17

18
    "github.com/gin-gonic/gin"
19
    "go.opentelemetry.io/otel/attribute"
20
)
21

22
// WorkerAdminHandler handles worker administration endpoints
23
type WorkerAdminHandler struct {
24
    userService          services.UserServiceInterface
25
    questionService      services.QuestionServiceInterface
26
    aiService            services.AIServiceInterface
27
    config               *config.Config
28
    worker               *worker.Worker
29
    workerService        services.WorkerServiceInterface
30
    templates            *template.Template
31
    learningService      services.LearningServiceInterface
32
    dailyQuestionService services.DailyQuestionServiceInterface
33
    logger               *observability.Logger
34
}
35

36
// NewWorkerAdminHandlerWithLogger creates a new WorkerAdminHandler
37
func NewWorkerAdminHandlerWithLogger(
38
    userService services.UserServiceInterface,
39
    questionService services.QuestionServiceInterface,
40
    aiService services.AIServiceInterface,
41
    cfg *config.Config,
42
    worker *worker.Worker,
43
    workerService services.WorkerServiceInterface,
44
    learningService services.LearningServiceInterface,
45
    dailyQuestionService services.DailyQuestionServiceInterface,
46
    logger *observability.Logger,
47
11x
) *WorkerAdminHandler {
48
11x
    return &WorkerAdminHandler{
49
11x
        userService:          userService,
50
11x
        questionService:      questionService,
51
11x
        aiService:            aiService,
52
11x
        config:               cfg,
53
11x
        worker:               worker,
54
11x
        workerService:        workerService,
55
11x
        templates:            nil,
56
11x
        learningService:      learningService,
57
11x
        dailyQuestionService: dailyQuestionService,
58
11x
        logger:               logger,
59
11x
    }
60
11x
}
61

62
// GetWorkerDetails returns detailed worker information
63
3x
func (h *WorkerAdminHandler) GetWorkerDetails(c *gin.Context) {
64
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_worker_details")
65
3x
    defer span.End()
66
3x
    // Get worker status from local instance if available
67
3x
    var localStatus worker.Status
68
3x
    var localHistory []worker.RunRecord
69
3x
    if h.worker != nil {
70
3x
        localStatus = h.worker.GetStatus()
71
3x
        localHistory = h.worker.GetHistory()
72
3x
    }
73

74
    // Get global pause status
75
3x
    globalPaused, err := h.workerService.IsGlobalPaused(ctx)
76
3x
    if err != nil {
77
        // Log the error but continue with default value
78
        h.logger.Warn(ctx, "Failed to get global pause status", map[string]interface{}{"error": err.Error()})
79
        globalPaused = false
80
    }
81

82
3x
    response := gin.H{
83
3x
        "status":        localStatus,
84
3x
        "history":       localHistory,
85
3x
        "global_paused": globalPaused,
86
3x
    }
87
3x

88
3x
    c.JSON(http.StatusOK, response)
89
}
90

91
// GetActivityLogs returns recent activity logs from the worker
92
1x
func (h *WorkerAdminHandler) GetActivityLogs(c *gin.Context) {
93
1x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_activity_logs")
94
1x
    defer span.End()
95
1x
    if h.worker == nil {
96
        HandleAppError(c, contextutils.ErrServiceUnavailable)
97
        return
98
    }
99

100
1x
    logs := h.worker.GetActivityLogs()
101
1x
    c.JSON(http.StatusOK, gin.H{"logs": logs})
102
}
103

104
// PauseWorker pauses the worker globally
105
3x
func (h *WorkerAdminHandler) PauseWorker(c *gin.Context) {
106
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "pause_worker")
107
3x
    defer span.End()
108
3x
    if err := h.workerService.SetGlobalPause(ctx, true); err != nil {
109
        HandleAppError(c, contextutils.WrapError(err, "failed to pause worker globally"))
110
        return
111
    }
112

113
    // Also pause the local worker instance if available
114
3x
    if h.worker != nil {
115
2x
        h.worker.Pause(ctx)
116
2x
    }
117

118
3x
    c.JSON(http.StatusOK, gin.H{"message": "Worker paused globally"})
119
}
120

121
// ResumeWorker resumes the worker globally
122
3x
func (h *WorkerAdminHandler) ResumeWorker(c *gin.Context) {
123
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "resume_worker")
124
3x
    defer span.End()
125
3x
    if err := h.workerService.SetGlobalPause(ctx, false); err != nil {
126
        HandleAppError(c, contextutils.WrapError(err, "failed to resume worker globally"))
127
        return
128
    }
129

130
    // Also resume the local worker instance if available
131
3x
    if h.worker != nil {
132
2x
        h.worker.Resume(ctx)
133
2x
    }
134

135
3x
    c.JSON(http.StatusOK, gin.H{"message": "Worker resumed globally"})
136
}
137

138
// GetWorkerStatus returns current worker status
139
6x
func (h *WorkerAdminHandler) GetWorkerStatus(c *gin.Context) {
140
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_worker_status")
141
6x
    defer span.End()
142
6x
    instance := c.DefaultQuery("instance", "default")
143
6x

144
6x
    status, err := h.workerService.GetWorkerStatus(ctx, instance)
145
6x
    if err != nil {
146
        HandleAppError(c, contextutils.WrapError(err, "failed to get worker status"))
147
        return
148
    }
149

150
6x
    c.JSON(http.StatusOK, status)
151
}
152

153
// TriggerWorkerRun triggers a manual worker run
154
2x
func (h *WorkerAdminHandler) TriggerWorkerRun(c *gin.Context) {
155
2x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "trigger_worker_run")
156
2x
    defer span.End()
157
2x
    if h.worker != nil {
158
2x
        h.worker.TriggerManualRun()
159
2x
        c.JSON(http.StatusOK, gin.H{"message": "Worker run triggered"})
160
2x
    } else {
161
        HandleAppError(c, contextutils.ErrServiceUnavailable)
162
    }
163
}
164

165
// PauseWorkerUser pauses question generation for a specific user
166
4x
func (h *WorkerAdminHandler) PauseWorkerUser(c *gin.Context) {
167
4x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "pause_user")
168
4x
    defer span.End()
169
4x
    var req struct {
170
4x
        UserID int `json:"user_id" binding:"required"`
171
4x
    }
172
4x

173
4x
    if err := c.ShouldBindJSON(&req); err != nil {
174
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
175
1x
            contextutils.ErrorCodeInvalidInput,
176
1x
            contextutils.SeverityWarn,
177
1x
            "Invalid request",
178
1x
            "",
179
1x
            err,
180
1x
        ))
181
1x
        return
182
1x
    }
183

184
3x
    if err := h.workerService.SetUserPause(ctx, req.UserID, true); err != nil {
185
        HandleAppError(c, contextutils.WrapError(err, "failed to pause user"))
186
        return
187
    }
188

189
3x
    c.JSON(http.StatusOK, gin.H{"message": "User paused successfully"})
190
}
191

192
// ResumeWorkerUser resumes question generation for a specific user
193
3x
func (h *WorkerAdminHandler) ResumeWorkerUser(c *gin.Context) {
194
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "resume_user")
195
3x
    defer span.End()
196
3x
    var req struct {
197
3x
        UserID int `json:"user_id" binding:"required"`
198
3x
    }
199
3x

200
3x
    if err := c.ShouldBindJSON(&req); err != nil {
201
1x
        HandleAppError(c, contextutils.NewAppErrorWithCause(
202
1x
            contextutils.ErrorCodeInvalidInput,
203
1x
            contextutils.SeverityWarn,
204
1x
            "Invalid request",
205
1x
            "",
206
1x
            err,
207
1x
        ))
208
1x
        return
209
1x
    }
210

211
2x
    if err := h.workerService.SetUserPause(ctx, req.UserID, false); err != nil {
212
        HandleAppError(c, contextutils.WrapError(err, "failed to resume user"))
213
        return
214
    }
215

216
2x
    c.JSON(http.StatusOK, gin.H{"message": "User resumed successfully"})
217
}
218

219
// GetWorkerUsers returns basic user list for worker controls
220
1x
func (h *WorkerAdminHandler) GetWorkerUsers(c *gin.Context) {
221
1x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_worker_users")
222
1x
    defer span.End()
223
1x
    users, err := h.userService.GetAllUsers(ctx)
224
1x
    if err != nil {
225
        HandleAppError(c, contextutils.WrapError(err, "failed to get users"))
226
        return
227
    }
228

229
    // Add pause status for each user
230
1x
    var userList []gin.H
231
1x
    for _, user := range users {
232
1x
        isPaused, _ := h.workerService.IsUserPaused(ctx, user.ID)
233
1x
        userList = append(userList, gin.H{
234
1x
            "id":        user.ID,
235
1x
            "username":  user.Username,
236
1x
            "is_paused": isPaused,
237
1x
        })
238
1x
    }
239

240
1x
    c.JSON(http.StatusOK, gin.H{"users": userList})
241
}
242

243
// GetSystemHealth returns comprehensive system health
244
2x
func (h *WorkerAdminHandler) GetSystemHealth(c *gin.Context) {
245
2x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_system_health")
246
2x
    defer span.End()
247
2x
    health, err := h.workerService.GetWorkerHealth(ctx)
248
2x
    if err != nil {
249
        HandleAppError(c, contextutils.WrapError(err, "failed to get system health"))
250
        return
251
    }
252

253
2x
    c.JSON(http.StatusOK, health)
254
}
255

256
// GetAIConcurrencyStats returns AI service concurrency metrics from the worker
257
1x
func (h *WorkerAdminHandler) GetAIConcurrencyStats(c *gin.Context) {
258
1x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_ai_concurrency_stats")
259
1x
    defer span.End()
260
1x
    if h.aiService == nil {
261
        HandleAppError(c, contextutils.ErrAIProviderUnavailable)
262
        return
263
    }
264

265
1x
    stats := h.aiService.GetConcurrencyStats()
266
1x
    c.JSON(http.StatusOK, gin.H{
267
1x
        "ai_concurrency": stats,
268
1x
    })
269
}
270

271
// GetPriorityAnalytics returns priority system analytics
272
6x
func (h *WorkerAdminHandler) GetPriorityAnalytics(c *gin.Context) {
273
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_priority_analytics")
274
6x
    defer span.End()
275
6x
    // Get priority score distribution
276
6x
    distribution, err := h.learningService.GetPriorityScoreDistribution(ctx)
277
6x
    if err != nil {
278
1x
        h.logger.Error(ctx, "Error getting priority score distribution", err, map[string]interface{}{})
279
1x
        distribution = map[string]interface{}{
280
1x
            "high":    0,
281
1x
            "medium":  0,
282
1x
            "low":     0,
283
1x
            "average": 0.0,
284
1x
        }
285
1x
    }
286

287
    // Get high priority questions
288
6x
    highPriorityQuestions, err := h.learningService.GetHighPriorityQuestions(ctx, 5)
289
6x
    if err != nil {
290
        h.logger.Error(ctx, "Error getting high priority questions", err, map[string]interface{}{})
291
        highPriorityQuestions = []map[string]interface{}{}
292
    }
293

294
6x
    response := gin.H{
295
6x
        "distribution":          distribution,
296
6x
        "highPriorityQuestions": highPriorityQuestions,
297
6x
    }
298
6x

299
6x
    c.JSON(http.StatusOK, response)
300
}
301

302
// GetUserPriorityAnalytics returns priority analytics for a specific user
303
3x
func (h *WorkerAdminHandler) GetUserPriorityAnalytics(c *gin.Context) {
304
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_user_priority_analytics")
305
3x
    defer span.End()
306
3x
    userIDStr := c.Param("userID")
307
3x
    userID, err := strconv.Atoi(userIDStr)
308
3x
    if err != nil {
309
1x
        HandleAppError(c, contextutils.ErrInvalidFormat)
310
1x
        return
311
1x
    }
312

313
    // Verify user exists
314
2x
    user, err := h.userService.GetUserByID(ctx, userID)
315
2x
    if err != nil || user == nil {
316
1x
        HandleAppError(c, contextutils.ErrRecordNotFound)
317
1x
        return
318
1x
    }
319

320
    // Get user-specific priority score distribution
321
1x
    distribution, err := h.learningService.GetUserPriorityScoreDistribution(ctx, userID)
322
1x
    if err != nil {
323
        h.logger.Error(ctx, "Error getting user priority score distribution", err, map[string]interface{}{})
324
        distribution = map[string]interface{}{
325
            "high":    0,
326
            "medium":  0,
327
            "low":     0,
328
            "average": 0.0,
329
        }
330
    }
331

332
    // Get user's high priority questions
333
1x
    highPriorityQuestions, err := h.learningService.GetUserHighPriorityQuestions(ctx, userID, 10)
334
1x
    if err != nil {
335
        h.logger.Error(ctx, "Error getting user high priority questions", err, map[string]interface{}{})
336
        highPriorityQuestions = []map[string]interface{}{}
337
    }
338

339
    // Get user's weak areas
340
1x
    weakAreas, err := h.learningService.GetUserWeakAreas(ctx, userID, 5)
341
1x
    if err != nil {
342
        h.logger.Error(ctx, "Error getting user weak areas", err, map[string]interface{}{})
343
        weakAreas = []map[string]interface{}{}
344
    }
345

346
    // Get user's learning preferences
347
1x
    preferences, err := h.learningService.GetUserLearningPreferences(ctx, userID)
348
1x
    if err != nil {
349
        h.logger.Error(ctx, "Error getting user learning preferences", err, map[string]interface{}{})
350
        preferences = nil
351
    }
352

353
1x
    response := gin.H{
354
1x
        "user": gin.H{
355
1x
            "id":       user.ID,
356
1x
            "username": user.Username,
357
1x
        },
358
1x
        "distribution":          distribution,
359
1x
        "highPriorityQuestions": highPriorityQuestions,
360
1x
        "weakAreas":             weakAreas,
361
1x
        "learningPreferences":   preferences,
362
1x
    }
363
1x

364
1x
    c.JSON(http.StatusOK, response)
365
}
366

367
// GetUserPerformanceAnalytics returns user performance analytics
368
4x
func (h *WorkerAdminHandler) GetUserPerformanceAnalytics(c *gin.Context) {
369
4x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_user_performance_analytics")
370
4x
    defer span.End()
371
4x
    // Get weak areas by topic
372
4x
    weakAreas, err := h.learningService.GetWeakAreasByTopic(ctx, 5)
373
4x
    if err != nil {
374
        h.logger.Error(ctx, "Error getting weak areas", err, map[string]interface{}{})
375
        weakAreas = []map[string]interface{}{}
376
    }
377

378
    // Get learning preferences usage
379
4x
    learningPreferences, err := h.learningService.GetLearningPreferencesUsage(ctx)
380
4x
    if err != nil {
381
        h.logger.Error(ctx, "Error getting learning preferences usage", err, map[string]interface{}{})
382
        learningPreferences = map[string]interface{}{}
383
    }
384

385
4x
    response := gin.H{
386
4x
        "weakAreas":           weakAreas,
387
4x
        "learningPreferences": learningPreferences,
388
4x
    }
389
4x

390
4x
    c.JSON(http.StatusOK, response)
391
}
392

393
// GetGenerationIntelligence returns question generation intelligence
394
4x
func (h *WorkerAdminHandler) GetGenerationIntelligence(c *gin.Context) {
395
4x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_generation_intelligence")
396
4x
    defer span.End()
397
4x
    // Get gap analysis
398
4x
    gapAnalysis, err := h.learningService.GetQuestionTypeGaps(ctx)
399
4x
    if err != nil {
400
        h.logger.Error(ctx, "Error getting gap analysis", err, map[string]interface{}{})
401
        gapAnalysis = []map[string]interface{}{}
402
    }
403

404
    // Get generation suggestions
405
4x
    generationSuggestions, err := h.learningService.GetGenerationSuggestions(ctx)
406
4x
    if err != nil {
407
        h.logger.Error(ctx, "Error getting generation suggestions", err, map[string]interface{}{})
408
        generationSuggestions = []map[string]interface{}{}
409
    }
410

411
    // Ensure we always return arrays, not nil
412
4x
    if gapAnalysis == nil {
413
2x
        gapAnalysis = []map[string]interface{}{}
414
2x
    }
415
4x
    if generationSuggestions == nil {
416
2x
        generationSuggestions = []map[string]interface{}{}
417
2x
    }
418

419
4x
    response := gin.H{
420
4x
        "gapAnalysis":           gapAnalysis,
421
4x
        "generationSuggestions": generationSuggestions,
422
4x
    }
423
4x

424
4x
    c.JSON(http.StatusOK, response)
425
}
426

427
// GetSystemHealthAnalytics returns system health analytics
428
3x
func (h *WorkerAdminHandler) GetSystemHealthAnalytics(c *gin.Context) {
429
3x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_system_health_analytics")
430
3x
    defer span.End()
431
3x
    // Get performance metrics
432
3x
    performance, err := h.learningService.GetPrioritySystemPerformance(ctx)
433
3x
    if err != nil {
434
        h.logger.Error(ctx, "Error getting performance metrics", err, map[string]interface{}{})
435
        performance = map[string]interface{}{}
436
    }
437

438
    // Get background jobs status
439
3x
    backgroundJobs, err := h.learningService.GetBackgroundJobsStatus(ctx)
440
3x
    if err != nil {
441
        h.logger.Error(ctx, "Error getting background jobs status", err, map[string]interface{}{})
442
        backgroundJobs = map[string]interface{}{}
443
    }
444

445
3x
    response := gin.H{
446
3x
        "performance":    performance,
447
3x
        "backgroundJobs": backgroundJobs,
448
3x
    }
449
3x

450
3x
    c.JSON(http.StatusOK, response)
451
}
452

453
// GetUserComparisonAnalytics returns comparison analytics between users
454
6x
func (h *WorkerAdminHandler) GetUserComparisonAnalytics(c *gin.Context) {
455
6x
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_user_comparison_analytics")
456
6x
    defer span.End()
457
6x
    userIDsParam := c.Query("user_ids")
458
6x
    if userIDsParam == "" {
459
1x
        HandleAppError(c, contextutils.ErrMissingRequired)
460
1x
        return
461
1x
    }
462

463
    // Split comma-separated user IDs
464
5x
    userIDsStr := strings.Split(userIDsParam, ",")
465
5x
    if len(userIDsStr) == 0 {
466
        HandleAppError(c, contextutils.ErrMissingRequired)
467
        return
468
    }
469

470
5x
    var userIDs []int
471
5x
    for _, idStr := range userIDsStr {
472
6x
        idStr = strings.TrimSpace(idStr) // Remove whitespace
473
6x
        if idStr == "" {
474
            continue
475
        }
476
6x
        id, err := strconv.Atoi(idStr)
477
6x
        if err != nil {
478
2x
            HandleAppError(c, contextutils.NewAppErrorWithCause(
479
2x
                contextutils.ErrorCodeInvalidFormat,
480
2x
                contextutils.SeverityWarn,
481
2x
                "Invalid user ID",
482
2x
                idStr,
483
2x
                err,
484
2x
            ))
485
2x
            return
486
2x
        }
487
4x
        userIDs = append(userIDs, id)
488
    }
489

490
3x
    if len(userIDs) == 0 {
491
        HandleAppError(c, contextutils.ErrMissingRequired)
492
        return
493
    }
494

495
    // Get comparison data for each user
496
3x
    var comparisonData []gin.H
497
3x
    for _, userID := range userIDs {
498
4x
        user, err := h.userService.GetUserByID(ctx, userID)
499
4x
        if err != nil {
500
            continue // Skip invalid users
501
        }
502

503
4x
        distribution, _ := h.learningService.GetUserPriorityScoreDistribution(ctx, userID)
504
4x
        weakAreas, _ := h.learningService.GetUserWeakAreas(ctx, userID, 3)
505
4x

506
4x
        userData := gin.H{
507
4x
            "user": gin.H{
508
4x
                "id":       user.ID,
509
4x
                "username": user.Username,
510
4x
            },
511
4x
            "distribution": distribution,
512
4x
            "weakAreas":    weakAreas,
513
4x
        }
514
4x
        comparisonData = append(comparisonData, userData)
515
    }
516

517
3x
    c.JSON(http.StatusOK, gin.H{"comparison": comparisonData})
518
}
519

520
// GetConfigz returns the merged config as pretty-printed JSON
521
1x
func (h *WorkerAdminHandler) GetConfigz(c *gin.Context) {
522
1x
    _, span := observability.TraceHandlerFunction(c.Request.Context(), "get_configz")
523
1x
    defer span.End()
524
1x
    c.IndentedJSON(http.StatusOK, h.config)
525
1x
}
526

527
// GetNotificationStats returns comprehensive notification statistics
528
func (h *WorkerAdminHandler) GetNotificationStats(c *gin.Context) {
529
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_notification_stats")
530
    defer span.End()
531

532
    // Get notification statistics from database
533
    stats, err := h.workerService.GetNotificationStats(ctx)
534
    if err != nil {
535
        h.logger.Error(ctx, "Failed to get notification stats", err, nil)
536
        c.JSON(http.StatusInternalServerError, gin.H{
537
            "error":   "Failed to get notification statistics",
538
            "details": err.Error(),
539
        })
540
        return
541
    }
542

543
    c.JSON(http.StatusOK, stats)
544
}
545

546
// GetNotificationErrors returns paginated notification errors
547
func (h *WorkerAdminHandler) GetNotificationErrors(c *gin.Context) {
548
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_notification_errors")
549
    defer span.End()
550

551
    // Parse pagination and filters
552
    page, pageSize := ParsePagination(c, 1, 20, 100)
553
    f := ParseFilters(c, "error_type", "notification_type", "resolved")
554
    errorType := f["error_type"]
555
    notificationType := f["notification_type"]
556
    resolved := f["resolved"]
557

558
    // Get notification errors from database
559
    errors, pagination, stats, err := h.workerService.GetNotificationErrors(ctx, page, pageSize, errorType, notificationType, resolved)
560
    if err != nil {
561
        h.logger.Error(ctx, "Failed to get notification errors", err, nil)
562
        c.JSON(http.StatusInternalServerError, gin.H{
563
            "error":   "Failed to get notification errors",
564
            "details": err.Error(),
565
        })
566
        return
567
    }
568

569
    WritePaginated(c, "errors", errors, pagination, gin.H{"stats": stats})
570
}
571

572
// GetSentNotifications returns paginated sent notifications
573
func (h *WorkerAdminHandler) GetSentNotifications(c *gin.Context) {
574
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "get_sent_notifications")
575
    defer span.End()
576

577
    // Parse pagination and filters
578
    page, pageSize := ParsePagination(c, 1, 20, 100)
579
    f := ParseFilters(c, "notification_type", "status", "sent_after", "sent_before")
580
    notificationType := f["notification_type"]
581
    status := f["status"]
582
    sentAfter := f["sent_after"]
583
    sentBefore := f["sent_before"]
584

585
    // Get sent notifications from database
586
    notifications, pagination, stats, err := h.workerService.GetSentNotifications(ctx, page, pageSize, notificationType, status, sentAfter, sentBefore)
587
    if err != nil {
588
        h.logger.Error(ctx, "Failed to get sent notifications", err, nil)
589
        c.JSON(http.StatusInternalServerError, gin.H{
590
            "error":   "Failed to get sent notifications",
591
            "details": err.Error(),
592
        })
593
        return
594
    }
595

596
    WritePaginated(c, "notifications", notifications, pagination, gin.H{"stats": stats})
597
}
598

599
// CreateTestSentNotification creates a test sent notification for testing
600
func (h *WorkerAdminHandler) CreateTestSentNotification(c *gin.Context) {
601
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "create_test_sent_notification")
602
    defer span.End()
603

604
    // Parse request body
605
    var request struct {
606
        UserID           int    `json:"user_id" binding:"required"`
607
        NotificationType string `json:"notification_type" binding:"required"`
608
        Subject          string `json:"subject" binding:"required"`
609
        TemplateName     string `json:"template_name" binding:"required"`
610
        Status           string `json:"status" binding:"required"`
611
        ErrorMessage     string `json:"error_message"`
612
    }
613

614
    if err := c.ShouldBindJSON(&request); err != nil {
615
        HandleAppError(c, contextutils.NewAppErrorWithCause(
616
            contextutils.ErrorCodeInvalidInput,
617
            contextutils.SeverityWarn,
618
            "Invalid request body",
619
            "",
620
            err,
621
        ))
622
        return
623
    }
624

625
    // Create test notification
626
    err := h.workerService.CreateTestSentNotification(ctx, request.UserID, request.NotificationType, request.Subject, request.TemplateName, request.Status, request.ErrorMessage)
627
    if err != nil {
628
        h.logger.Error(ctx, "Failed to create test sent notification", err, map[string]interface{}{
629
            "user_id":           request.UserID,
630
            "notification_type": request.NotificationType,
631
        })
632
        c.JSON(http.StatusInternalServerError, gin.H{
633
            "error":   "Failed to create test sent notification",
634
            "details": err.Error(),
635
        })
636
        return
637
    }
638

639
    c.JSON(http.StatusOK, gin.H{"message": "Test sent notification created successfully"})
640
}
641

642
// ForceSendNotification forces sending a notification to a user, bypassing normal checks
643
func (h *WorkerAdminHandler) ForceSendNotification(c *gin.Context) {
644
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "force_send_notification")
645
    defer span.End()
646

647
    // Parse request body
648
    var request struct {
649
        Username string `json:"username" binding:"required"`
650
    }
651

652
    if err := c.ShouldBindJSON(&request); err != nil {
653
        HandleAppError(c, contextutils.NewAppErrorWithCause(
654
            contextutils.ErrorCodeInvalidInput,
655
            contextutils.SeverityWarn,
656
            "Invalid request body",
657
            "",
658
            err,
659
        ))
660
        return
661
    }
662

663
    // Get user by username
664
    user, err := h.userService.GetUserByUsername(ctx, request.Username)
665
    if err != nil {
666
        h.logger.Error(ctx, "Failed to get user by username", err, map[string]interface{}{
667
            "username": request.Username,
668
        })
669
        c.JSON(http.StatusInternalServerError, gin.H{
670
            "error":   "Failed to get user",
671
            "details": err.Error(),
672
        })
673
        return
674
    }
675

676
    if user == nil {
677
        HandleAppError(c, contextutils.NewAppError(
678
            contextutils.ErrorCodeRecordNotFound,
679
            contextutils.SeverityInfo,
680
            fmt.Sprintf("User '%s' not found", request.Username),
681
            "",
682
        ))
683
        return
684
    }
685

686
    // Check if user has email address
687
    if !user.Email.Valid || user.Email.String == "" {
688
        HandleAppError(c, contextutils.ErrMissingRequired)
689
        return
690
    }
691

692
    // Get user's learning preferences to check daily reminder setting
693
    prefs, err := h.learningService.GetUserLearningPreferences(ctx, user.ID)
694
    if err != nil {
695
        h.logger.Error(ctx, "Failed to get user learning preferences", err, map[string]interface{}{
696
            "user_id": user.ID,
697
        })
698
        c.JSON(http.StatusInternalServerError, gin.H{
699
            "error":   "Failed to get user preferences",
700
            "details": err.Error(),
701
        })
702
        return
703
    }
704

705
    // Check if daily reminders are enabled for this user
706
    if prefs == nil || !prefs.DailyReminderEnabled {
707
        HandleAppError(c, contextutils.NewAppError(contextutils.ErrorCodeInvalidInput, contextutils.SeverityWarn, "User has daily reminders disabled", ""))
708
        return
709
    }
710

711
    // Force send the daily reminder (bypassing time and date checks)
712
    subject := "Time for your daily quiz! ð"
713
    status := "sent"
714
    errorMsg := ""
715

716
    // Get email service from worker
717
    emailService := h.worker.GetEmailService()
718
    if emailService == nil {
719
        HandleAppError(c, contextutils.ErrServiceUnavailable)
720
        return
721
    }
722

723
    // Send the email
724
    if err := emailService.SendDailyReminder(ctx, user); err != nil {
725
        h.logger.Error(ctx, "Failed to send forced daily reminder", err, map[string]interface{}{
726
            "user_id": user.ID,
727
            "email":   user.Email.String,
728
        })
729
        HandleAppError(c, contextutils.WrapError(err, "failed to send notification"))
730
        return
731
    }
732

733
    // Record the sent notification in the database
734
    if err := emailService.RecordSentNotification(ctx, user.ID, "daily_reminder", subject, "daily_reminder", status, errorMsg); err != nil {
735
        h.logger.Error(ctx, "Failed to record sent notification", err, map[string]interface{}{
736
            "user_id": user.ID,
737
        })
738
        // Don't fail the request if recording fails
739
    }
740

741
    // Update the last reminder sent timestamp for this user
742
    if err := h.learningService.UpdateLastDailyReminderSent(ctx, user.ID); err != nil {
743
        h.logger.Error(ctx, "Failed to update last daily reminder sent timestamp", err, map[string]interface{}{
744
            "user_id": user.ID,
745
        })
746
        // Don't fail the request if timestamp update fails
747
    }
748

749
    h.logger.Info(ctx, "Forced notification sent successfully", map[string]interface{}{
750
        "user_id":  user.ID,
751
        "username": user.Username,
752
        "email":    user.Email.String,
753
    })
754

755
    c.JSON(http.StatusOK, gin.H{
756
        "message": "Notification sent successfully",
757
        "user": gin.H{
758
            "id":       user.ID,
759
            "username": user.Username,
760
            "email":    user.Email.String,
761
        },
762
        "notification": gin.H{
763
            "type":    "daily_reminder",
764
            "subject": subject,
765
            "status":  status,
766
        },
767
    })
768
}
769

770
// GetUserDailyQuestions returns daily questions for a specific user and date (admin only)
771
func (h *WorkerAdminHandler) GetUserDailyQuestions(c *gin.Context) {
772
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "admin_get_user_daily_questions")
773
    defer span.End()
774

775
    // Parse user ID
776
    userIDStr := c.Param("userId")
777
    userID, err := strconv.Atoi(userIDStr)
778
    if err != nil {
779
        HandleAppError(c, contextutils.ErrInvalidFormat)
780
        return
781
    }
782

783
    // Check if user exists
784
    user, err := h.userService.GetUserByID(ctx, userID)
785
    if err != nil {
786
        h.logger.Error(ctx, "Failed to get user for daily questions", err, map[string]interface{}{"user_id": userID})
787
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
788
        return
789
    }
790
    if user == nil {
791
        HandleAppError(c, contextutils.ErrRecordNotFound)
792
        return
793
    }
794

795
    // Parse date
796
    dateStr := c.Param("date")
797
    if dateStr == "" {
798
        HandleAppError(c, contextutils.ErrMissingRequired)
799
        return
800
    }
801

802
    date, err := time.Parse("2006-01-02", dateStr)
803
    if err != nil {
804
        HandleAppError(c, contextutils.ErrInvalidFormat)
805
        return
806
    }
807

808
    // Add span attributes for observability
809
    span.SetAttributes(
810
        observability.AttributeUserID(userID),
811
        attribute.String("date", dateStr),
812
    )
813

814
    // Get daily questions for the user and date
815
    questions, err := h.dailyQuestionService.GetDailyQuestions(ctx, userID, date)
816
    if err != nil {
817
        h.logger.Error(ctx, "Failed to get user daily questions", err, map[string]interface{}{
818
            "user_id": userID,
819
            "date":    dateStr,
820
        })
821
        c.JSON(http.StatusInternalServerError, gin.H{
822
            "error":   "Failed to get daily questions",
823
            "details": err.Error(),
824
        })
825
        return
826
    }
827

828
    // Convert to API format (similar to the daily question handler)
829
    apiQuestions := make([]gin.H, len(questions))
830
    for i, q := range questions {
831
        var completedAt *time.Time
832
        if q.CompletedAt.Valid {
833
            completedAt = &q.CompletedAt.Time
834
        }
835

836
        apiQuestions[i] = gin.H{
837
            "id":              q.ID,
838
            "user_id":         q.UserID,
839
            "question_id":     q.QuestionID,
840
            "assignment_date": q.AssignmentDate,
841
            "is_completed":    q.IsCompleted,
842
            "completed_at":    completedAt,
843
            "created_at":      q.CreatedAt,
844
            // Per-user stats for admin UI
845
            "user_shown_count":     q.DailyShownCount,
846
            "user_total_responses": q.UserTotalResponses,
847
            "user_correct_count":   q.UserCorrectCount,
848
            "user_incorrect_count": q.UserIncorrectCount,
849
            "question": gin.H{
850
                "id":                  q.Question.ID,
851
                "type":                q.Question.Type,
852
                "language":            q.Question.Language,
853
                "level":               q.Question.Level,
854
                "difficulty_score":    q.Question.DifficultyScore,
855
                "content":             q.Question.Content,
856
                "correct_answer":      q.Question.CorrectAnswer,
857
                "explanation":         q.Question.Explanation,
858
                "created_at":          q.Question.CreatedAt,
859
                "status":              q.Question.Status,
860
                "topic_category":      q.Question.TopicCategory,
861
                "grammar_focus":       q.Question.GrammarFocus,
862
                "vocabulary_domain":   q.Question.VocabularyDomain,
863
                "scenario":            q.Question.Scenario,
864
                "style_modifier":      q.Question.StyleModifier,
865
                "difficulty_modifier": q.Question.DifficultyModifier,
866
                "time_context":        q.Question.TimeContext,
867
            },
868
        }
869
    }
870

871
    c.JSON(http.StatusOK, gin.H{"questions": apiQuestions})
872
}
873

874
// RegenerateUserDailyQuestions clears and regenerates daily questions for a specific user and date (admin only)
875
func (h *WorkerAdminHandler) RegenerateUserDailyQuestions(c *gin.Context) {
876
    ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "admin_regenerate_user_daily_questions")
877
    defer span.End()
878

879
    // Parse user ID
880
    userIDStr := c.Param("userId")
881
    userID, err := strconv.Atoi(userIDStr)
882
    if err != nil {
883
        HandleAppError(c, contextutils.ErrInvalidFormat)
884
        return
885
    }
886

887
    // Check if user exists
888
    user, err := h.userService.GetUserByID(ctx, userID)
889
    if err != nil {
890
        h.logger.Error(ctx, "Failed to get user for daily questions regeneration", err, map[string]interface{}{"user_id": userID})
891
        HandleAppError(c, contextutils.WrapError(err, "failed to get user"))
892
        return
893
    }
894
    if user == nil {
895
        HandleAppError(c, contextutils.ErrRecordNotFound)
896
        return
897
    }
898

899
    // Parse date
900
    dateStr := c.Param("date")
901
    if dateStr == "" {
902
        HandleAppError(c, contextutils.ErrMissingRequired)
903
        return
904
    }
905

906
    date, err := time.Parse("2006-01-02", dateStr)
907
    if err != nil {
908
        HandleAppError(c, contextutils.ErrInvalidFormat)
909
        return
910
    }
911

912
    // Add span attributes for observability
913
    span.SetAttributes(
914
        observability.AttributeUserID(userID),
915
        attribute.String("date", dateStr),
916
    )
917

918
    // For regeneration, we need to manually clear existing assignments and create new ones
919
    // Since the daily question service doesn't expose a direct way to clear assignments,
920
    // we'll use the worker service which should have database access for this admin operation
921

922
    // Check if worker service is available
923
    if h.workerService == nil {
924
        HandleAppError(c, contextutils.ErrServiceUnavailable)
925
        return
926
    }
927

928
    // Use the new RegenerateDailyQuestions method which clears existing assignments and creates new ones
929
    err = h.dailyQuestionService.RegenerateDailyQuestions(ctx, userID, date)
930
    if err != nil {
931
        h.logger.Error(ctx, "Failed to regenerate daily questions", err, map[string]interface{}{
932
            "user_id": userID,
933
            "date":    dateStr,
934
        })
935

936
        // If there are no questions available for assignment, prefer the structured error from the service
937
        var nqErr *services.NoQuestionsAvailableError
938
        if errors.As(err, &nqErr) {
939
            c.JSON(http.StatusBadRequest, gin.H{
940
                "error":                    "Failed to regenerate daily questions",
941
                "details":                  err.Error(),
942
                "user":                     gin.H{"id": user.ID, "username": user.Username, "language": nqErr.Language, "level": nqErr.Level},
943
                "candidate_count":          nqErr.CandidateCount,
944
                "candidate_ids":            nqErr.CandidateIDs,
945
                "total_matching_questions": nqErr.TotalMatching,
946
            })
947
            return
948
        }
949

950
        c.JSON(http.StatusInternalServerError, gin.H{
951
            "error":   "Failed to regenerate daily questions",
952
            "details": err.Error(),
953
        })
954
        return
955
    }
956

957
    h.logger.Info(ctx, "Daily questions regenerated successfully", map[string]interface{}{
958
        "user_id": userID,
959
        "date":    dateStr,
960
    })
961

962
    c.JSON(http.StatusOK, gin.H{"success": true, "message": "Daily questions regenerated successfully. All existing assignments have been cleared and new questions assigned."})
963
}
964


			
quizapp internal middleware
12.0%
Statements
71/593
auth.go
34.8%
23/66
error_recovery.go
51.6%
48/93
schema_loader.go
0.0%
0/334
validation.go
0.0%
0/100
quizapp internal middleware validation.go
34.8%
Statements
23/66
1
// Package middleware provides authentication and authorization middleware for the Gin web framework.
2
package middleware
3

4
import (
5
    "context"
6
    "net/http"
7

8
    "github.com/gin-contrib/sessions"
9
    "github.com/gin-gonic/gin"
10
)
11

12
// Session keys for storing user information
13
const (
14
    // UserIDKey is the key used to store user ID in session
15
    UserIDKey = "user_id"
16
    // UsernameKey is the key used to store username in session
17
    UsernameKey = "username"
18
)
19

20
// RequireAuth returns a middleware that requires authentication
21
7x
func RequireAuth() gin.HandlerFunc {
22
7x
    return func(c *gin.Context) {
23
9x
        session := sessions.Default(c)
24
9x
        userID := session.Get(UserIDKey)
25
9x

26
9x
        if userID == nil {
27
2x
            c.JSON(http.StatusUnauthorized, gin.H{
28
2x
                "error": "Authentication required",
29
2x
                "code":  "UNAUTHORIZED",
30
2x
            })
31
2x
            c.Abort()
32
2x
            return
33
2x
        }
34

35
        // Validate user_id is an integer
36
7x
        userIDInt, ok := userID.(int)
37
7x
        if !ok {
38
1x
            // Try to convert from float64 (JSON numbers are often stored as float64)
39
1x
            if userIDFloat, ok := userID.(float64); ok {
40
                userIDInt = int(userIDFloat)
41
            } else {
42
1x
                c.JSON(http.StatusUnauthorized, gin.H{
43
1x
                    "error": "Authentication required",
44
1x
                    "code":  "UNAUTHORIZED",
45
1x
                })
46
1x
                c.Abort()
47
1x
                return
48
1x
            }
49
        }
50

51
        // Validate username is a string and not empty
52
6x
        username := session.Get(UsernameKey)
53
6x
        if username == nil {
54
1x
            c.JSON(http.StatusUnauthorized, gin.H{
55
1x
                "error": "Authentication required",
56
1x
                "code":  "UNAUTHORIZED",
57
1x
            })
58
1x
            c.Abort()
59
1x
            return
60
1x
        }
61

62
5x
        usernameStr, ok := username.(string)
63
5x
        if !ok || usernameStr == "" {
64
            c.JSON(http.StatusUnauthorized, gin.H{
65
                "error": "Authentication required",
66
                "code":  "UNAUTHORIZED",
67
            })
68
            c.Abort()
69
            return
70
        }
71

72
        // Store user info in context for handlers to use
73
5x
        c.Set(UserIDKey, userIDInt)
74
5x
        c.Set(UsernameKey, usernameStr)
75
5x

76
5x
        c.Next()
77
    }
78
}
79

80
// RequireAdmin returns a middleware that requires authentication and admin role
81
func RequireAdmin(userService interface{}) gin.HandlerFunc {
82
    // Type assertion to get the user service
83
    us, ok := userService.(interface {
84
        IsAdmin(ctx context.Context, userID int) (bool, error)
85
    })
86
    if !ok {
87
        panic("userService must implement IsAdmin method")
88
    }
89

90
    return func(c *gin.Context) {
91
        // First check authentication
92
        session := sessions.Default(c)
93
        userID := session.Get(UserIDKey)
94

95
        if userID == nil {
96
            c.JSON(http.StatusUnauthorized, gin.H{
97
                "error": "Authentication required",
98
                "code":  "UNAUTHORIZED",
99
            })
100
            c.Abort()
101
            return
102
        }
103

104
        // Validate user_id is an integer
105
        userIDInt, ok := userID.(int)
106
        if !ok {
107
            // Try to convert from float64 (JSON numbers are often stored as float64)
108
            if userIDFloat, ok := userID.(float64); ok {
109
                userIDInt = int(userIDFloat)
110
            } else {
111
                c.JSON(http.StatusUnauthorized, gin.H{
112
                    "error": "Authentication required",
113
                    "code":  "UNAUTHORIZED",
114
                })
115
                c.Abort()
116
                return
117
            }
118
        }
119

120
        // Validate username is a string and not empty
121
        username := session.Get(UsernameKey)
122
        if username == nil {
123
            c.JSON(http.StatusUnauthorized, gin.H{
124
                "error": "Authentication required",
125
                "code":  "UNAUTHORIZED",
126
            })
127
            c.Abort()
128
            return
129
        }
130

131
        usernameStr, ok := username.(string)
132
        if !ok || usernameStr == "" {
133
            c.JSON(http.StatusUnauthorized, gin.H{
134
                "error": "Authentication required",
135
                "code":  "UNAUTHORIZED",
136
            })
137
            c.Abort()
138
            return
139
        }
140

141
        // Check if user has admin role
142
        isAdmin, err := us.IsAdmin(c.Request.Context(), userIDInt)
143
        if err != nil {
144
            c.JSON(http.StatusInternalServerError, gin.H{
145
                "error": "Failed to check admin status",
146
                "code":  "INTERNAL_ERROR",
147
            })
148
            c.Abort()
149
            return
150
        }
151

152
        if !isAdmin {
153
            c.JSON(http.StatusForbidden, gin.H{
154
                "error": "Admin access required",
155
                "code":  "FORBIDDEN",
156
            })
157
            c.Abort()
158
            return
159
        }
160

161
        // Store user info in context for handlers to use
162
        c.Set(UserIDKey, userIDInt)
163
        c.Set(UsernameKey, usernameStr)
164

165
        c.Next()
166
    }
167
}
168


			
quizapp internal middleware validation.go
51.6%
Statements
48/93
1
package middleware
2

3
import (
4
    "fmt"
5
    "net/http"
6
    "runtime/debug"
7
    "time"
8

9
    contextutils "quizapp/internal/utils"
10

11
    "github.com/gin-gonic/gin"
12
)
13

14
// ErrorRecoveryConfig configures error recovery behavior
15
type ErrorRecoveryConfig struct {
16
    // MaxRetries specifies the maximum number of retries for retryable errors
17
    MaxRetries int
18
    // RetryDelay specifies the base delay between retries
19
    RetryDelay time.Duration
20
    // MaxRetryDelay specifies the maximum delay between retries
21
    MaxRetryDelay time.Duration
22
    // EnableCircuitBreaker enables circuit breaker pattern
23
    EnableCircuitBreaker bool
24
    // CircuitBreakerThreshold specifies failure threshold for circuit breaker
25
    CircuitBreakerThreshold int
26
    // CircuitBreakerTimeout specifies how long to wait before retrying after circuit opens
27
    CircuitBreakerTimeout time.Duration
28
}
29

30
// DefaultErrorRecoveryConfig returns a default error recovery configuration
31
3x
func DefaultErrorRecoveryConfig() *ErrorRecoveryConfig {
32
3x
    return &ErrorRecoveryConfig{
33
3x
        MaxRetries:              3,
34
3x
        RetryDelay:              100 * time.Millisecond,
35
3x
        MaxRetryDelay:           5 * time.Second,
36
3x
        EnableCircuitBreaker:    false,
37
3x
        CircuitBreakerThreshold: 5,
38
3x
        CircuitBreakerTimeout:   30 * time.Second,
39
3x
    }
40
3x
}
41

42
// circuitBreakerState represents the state of a circuit breaker
43
type circuitBreakerState int
44

45
const (
46
    circuitClosed circuitBreakerState = iota
47
    circuitOpen
48
    circuitHalfOpen
49
)
50

51
// circuitBreaker tracks failures and manages circuit state
52
type circuitBreaker struct {
53
    state       circuitBreakerState
54
    failures    int
55
    lastFailure time.Time
56
    config      *ErrorRecoveryConfig
57
}
58

59
// newCircuitBreaker creates a new circuit breaker
60
1x
func newCircuitBreaker(config *ErrorRecoveryConfig) *circuitBreaker {
61
1x
    return &circuitBreaker{
62
1x
        state:  circuitClosed,
63
1x
        config: config,
64
1x
    }
65
1x
}
66

67
// canExecute checks if the circuit breaker allows execution
68
4x
func (cb *circuitBreaker) canExecute() bool {
69
4x
    switch cb.state {
70
2x
    case circuitClosed:
71
2x
        return true
72
2x
    case circuitOpen:
73
2x
        if time.Since(cb.lastFailure) > cb.config.CircuitBreakerTimeout {
74
1x
            cb.state = circuitHalfOpen
75
1x
            return true
76
1x
        }
77
1x
        return false
78
    case circuitHalfOpen:
79
        return true
80
    default:
81
        return false
82
    }
83
}
84

85
// recordSuccess records a successful execution
86
1x
func (cb *circuitBreaker) recordSuccess() {
87
1x
    cb.failures = 0
88
1x
    cb.state = circuitClosed
89
1x
}
90

91
// recordFailure records a failed execution
92
2x
func (cb *circuitBreaker) recordFailure() {
93
2x
    cb.failures++
94
2x
    cb.lastFailure = time.Now()
95
2x

96
2x
    if cb.failures >= cb.config.CircuitBreakerThreshold {
97
1x
        cb.state = circuitOpen
98
1x
    }
99
}
100

101
// ErrorRecoveryMiddleware creates middleware for handling panics and retrying failed requests
102
2x
func ErrorRecoveryMiddleware(logger interface{}, config *ErrorRecoveryConfig) gin.HandlerFunc {
103
2x
    if config == nil {
104
2x
        config = DefaultErrorRecoveryConfig()
105
2x
    }
106

107
    // Create circuit breaker if enabled
108
2x
    var cb *circuitBreaker
109
2x
    if config.EnableCircuitBreaker {
110
        cb = newCircuitBreaker(config)
111
    }
112

113
2x
    return func(c *gin.Context) {
114
2x
        defer func() {
115
2x
            if err := recover(); err != nil {
116
1x
                // Log the panic with stack trace
117
1x
                stackTrace := string(debug.Stack())
118
1x
                fmt.Printf("Panic recovered: %v\nStack trace: %s\n", err, stackTrace)
119
1x

120
1x
                // Convert panic value to error if needed
121
1x
                var panicErr error
122
1x
                if e, ok := err.(error); ok {
123
                    panicErr = e
124
                } else {
125
1x
                    panicErr = contextutils.WrapErrorf(nil, "panic: %v", err)
126
1x
                }
127

128
                // Send error response
129
1x
                appErr := contextutils.NewAppErrorWithCause(
130
1x
                    contextutils.ErrorCodeInternalError,
131
1x
                    contextutils.SeverityFatal,
132
1x
                    "Internal server error",
133
1x
                    "A panic occurred while processing the request",
134
1x
                    contextutils.WrapError(panicErr, "panic"),
135
1x
                )
136
1x

137
1x
                // Add stack trace to error details in development
138
1x
                if gin.Mode() == gin.DebugMode {
139
                    appErr.Details = fmt.Sprintf("%s\nStack trace: %s", appErr.Details, stackTrace)
140
                }
141

142
1x
                HandleAppError(c, appErr)
143
1x
                c.Abort()
144
            }
145
        }()
146

147
        // Check circuit breaker
148
2x
        if cb != nil && !cb.canExecute() {
149
            ServiceUnavailable(c, "Service temporarily unavailable due to high error rate")
150
            c.Abort()
151
            return
152
        }
153

154
        // Process request
155
2x
        c.Next()
156
2x

157
2x
        // Record success/failure for circuit breaker
158
2x
        if cb != nil {
159
            if c.Writer.Status() >= 500 {
160
                cb.recordFailure()
161
            } else if c.Writer.Status() < 500 && cb.state == circuitHalfOpen {
162
                cb.recordSuccess()
163
            }
164
        }
165

166
        // Retry logic for failed requests
167
1x
        if shouldRetry(c.Writer.Status(), c.Errors) {
168
            retryWithBackoff(c, config, logger)
169
        }
170
    }
171
}
172

173
// shouldRetry determines if a request should be retried
174
6x
func shouldRetry(statusCode int, errors []*gin.Error) bool {
175
6x
    // Only retry 5xx errors and certain 4xx errors
176
6x
    if statusCode >= 500 {
177
1x
        return true
178
1x
    }
179

180
    // Retry on specific 4xx errors that might be transient
181
5x
    if statusCode == http.StatusRequestTimeout || statusCode == http.StatusTooManyRequests {
182
2x
        return true
183
2x
    }
184

185
    // Check if there are errors that indicate retryable failures
186
3x
    for _, err := range errors {
187
        if contextutils.IsRetryable(err) {
188
            return true
189
        }
190
    }
191

192
3x
    return false
193
}
194

195
// retryWithBackoff attempts to retry the request with exponential backoff
196
func retryWithBackoff(c *gin.Context, config *ErrorRecoveryConfig, logger interface{}) {
197
    // Only retry idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE)
198
    method := c.Request.Method
199
    if method != http.MethodGet && method != http.MethodHead &&
200
        method != http.MethodOptions && method != http.MethodPut &&
201
        method != http.MethodDelete {
202
        return
203
    }
204

205
    // Get the original handler
206
    handlerName := c.HandlerName()
207
    if handlerName == "" {
208
        return
209
    }
210

211
    // Calculate retry delay with exponential backoff
212
    delay := config.RetryDelay
213
    for i := 0; i < config.MaxRetries; i++ {
214
        time.Sleep(delay)
215

216
        // Double the delay for next iteration (with max limit)
217
        delay *= 2
218
        if delay > config.MaxRetryDelay {
219
            delay = config.MaxRetryDelay
220
        }
221

222
        // Log retry attempt
223
        if logger != nil {
224
            // This would be logged using the observability logger in real implementation
225
            fmt.Printf("Retrying request %s %s (attempt %d/%d)\n",
226
                method, c.Request.URL.Path, i+1, config.MaxRetries)
227
        }
228

229
        // Note: In a real implementation, we would need to recreate the request
230
        // and re-execute it. This is a simplified version for demonstration.
231
        // The actual retry logic would depend on the specific use case.
232
    }
233
}
234

235
// HandleAppError handles any AppError and sends appropriate HTTP response
236
1x
func HandleAppError(c *gin.Context, err error) {
237
1x
    if appErr, ok := err.(*contextutils.AppError); ok {
238
1x
        StandardizeAppError(c, appErr)
239
1x
    } else {
240
        // Fallback for non-AppError types
241
        StandardizeHTTPError(c, http.StatusInternalServerError, "Internal server error", err.Error())
242
    }
243
}
244

245
// StandardizeAppError sends a structured error response using AppError
246
1x
func StandardizeAppError(c *gin.Context, err *contextutils.AppError) {
247
1x
    // Map error codes to HTTP status codes
248
1x
    statusCode := mapErrorCodeToHTTPStatus(err.Code)
249
1x

250
1x
    // Convert error to JSON structure
251
1x
    errorJSON := err.ToJSON()
252
1x

253
1x
    // Add retryable information based on error type
254
1x
    errorJSON["retryable"] = contextutils.IsRetryable(err)
255
1x

256
1x
    c.JSON(statusCode, errorJSON)
257
1x
}
258

259
// StandardizeHTTPError creates consistent HTTP error responses with structured error information
260
func StandardizeHTTPError(c *gin.Context, _ int, message, details string) {
261
    // Create a generic AppError for consistent response format
262
    appErr := contextutils.NewAppError(
263
        contextutils.ErrorCodeInternalError,
264
        contextutils.SeverityError,
265
        message,
266
        details,
267
    )
268

269
    StandardizeAppError(c, appErr)
270
}
271

272
// ServiceUnavailable sends a 503 Service Unavailable error with a standardized payload
273
func ServiceUnavailable(c *gin.Context, msg string) {
274
    appErr := contextutils.NewAppError(
275
        contextutils.ErrorCodeServiceUnavailable,
276
        contextutils.SeverityError,
277
        msg,
278
        "",
279
    )
280
    StandardizeAppError(c, appErr)
281
}
282

283
// mapErrorCodeToHTTPStatus maps AppError codes to appropriate HTTP status codes
284
1x
func mapErrorCodeToHTTPStatus(code contextutils.ErrorCode) int {
285
1x
    switch code {
286
    // 4xx Client Errors
287
    case contextutils.ErrorCodeInvalidInput, contextutils.ErrorCodeMissingRequired,
288
        contextutils.ErrorCodeInvalidFormat, contextutils.ErrorCodeValidationFailed,
289
        contextutils.ErrorCodeOAuthStateMismatch:
290
        return http.StatusBadRequest
291

292
    case contextutils.ErrorCodeUnauthorized:
293
        return http.StatusUnauthorized
294

295
    case contextutils.ErrorCodeForbidden:
296
        return http.StatusForbidden
297

298
    case contextutils.ErrorCodeRecordNotFound, contextutils.ErrorCodeQuestionNotFound,
299
        contextutils.ErrorCodeAssignmentNotFound:
300
        return http.StatusNotFound
301

302
    case contextutils.ErrorCodeRecordExists:
303
        return http.StatusConflict
304

305
    case contextutils.ErrorCodeSessionExpired, contextutils.ErrorCodeInvalidCredentials:
306
        return http.StatusUnauthorized
307

308
    case contextutils.ErrorCodeRateLimit:
309
        return http.StatusTooManyRequests
310

311
    // 5xx Server Errors
312
1x
    case contextutils.ErrorCodeInternalError:
313
1x
        return http.StatusInternalServerError
314

315
    case contextutils.ErrorCodeServiceUnavailable, contextutils.ErrorCodeDatabaseConnection,
316
        contextutils.ErrorCodeAIProviderUnavailable:
317
        return http.StatusServiceUnavailable
318

319
    case contextutils.ErrorCodeTimeout:
320
        return http.StatusRequestTimeout
321

322
    case contextutils.ErrorCodeDatabaseQuery, contextutils.ErrorCodeDatabaseTransaction,
323
        contextutils.ErrorCodeForeignKeyViolation, contextutils.ErrorCodeTimestampMissingTimezone,
324
        contextutils.ErrorCodeAIRequestFailed, contextutils.ErrorCodeAIResponseInvalid,
325
        contextutils.ErrorCodeAIConfigInvalid, contextutils.ErrorCodeOAuthProviderError:
326
        return http.StatusInternalServerError
327

328
    // Default to internal server error for unknown codes
329
    default:
330
        return http.StatusInternalServerError
331
    }
332
}
333


			
quizapp internal middleware validation.go
0.0%
Statements
0/334
1
package middleware
2

3
import (
4
    "encoding/json"
5
    "fmt"
6
    "os"
7
    "strings"
8

9
    contextutils "quizapp/internal/utils"
10

11
    "github.com/xeipuuv/gojsonschema"
12
    "gopkg.in/yaml.v2"
13
)
14

15
// SchemaLoader loads JSON schemas from the Swagger specification
16
type SchemaLoader struct {
17
    schemas map[string]*gojsonschema.Schema
18
}
19

20
// NewSchemaLoader creates a new schema loader
21
func NewSchemaLoader() *SchemaLoader {
22
    return &SchemaLoader{
23
        schemas: make(map[string]*gojsonschema.Schema),
24
    }
25
}
26

27
// LoadSchemasFromSwagger loads all schemas from the Swagger specification
28
func (sl *SchemaLoader) LoadSchemasFromSwagger(swaggerPath string) error {
29
    // Read the Swagger file
30
    data, err := os.ReadFile(swaggerPath)
31
    if err != nil {
32
        return contextutils.WrapError(err, "failed to read swagger file")
33
    }
34

35
    // Parse the Swagger spec (YAML only)
36
    var swagger map[string]interface{}
37

38
    if err := yaml.Unmarshal(data, &swagger); err != nil {
39
        return contextutils.WrapError(err, "failed to parse swagger file as YAML")
40
    }
41

42
    fmt.Printf("â Successfully parsed swagger file as YAML\n")
43

44
    // Extract components/schemas
45
    components, ok := swagger["components"].(map[interface{}]interface{})
46
    if !ok {
47
        fmt.Printf("â No components section found. Available keys: %v\n", getKeys(swagger))
48
        fmt.Printf("â Components type: %T, value: %v\n", swagger["components"], swagger["components"])
49
        return contextutils.ErrorWithContextf("no components section found in swagger")
50
    }
51

52
    schemas, ok := components["schemas"].(map[interface{}]interface{})
53
    if !ok {
54
        fmt.Printf("â No schemas section found in components. Available keys: %v\n", getKeysInterface(components))
55
        fmt.Printf("â Schemas type: %T, value: %v\n", components["schemas"], components["schemas"])
56
        return contextutils.ErrorWithContextf("no schemas section found in swagger")
57
    }
58

59
    // Convert schemas to JSON-compatible format
60
    jsonCompatibleSchemas := make(map[string]interface{})
61
    for schemaName, schemaData := range schemas {
62
        schemaNameStr, ok := schemaName.(string)
63
        if !ok {
64
            fmt.Printf("Warning: schema name is not a string: %v\n", schemaName)
65
            continue
66
        }
67

68
        convertedSchema, err := convertToJSONCompatible(schemaData)
69
        if err != nil {
70
            fmt.Printf("Warning: failed to convert schema %s: %v\n", schemaNameStr, err)
71
            continue
72
        }
73

74
        jsonCompatibleSchemas[schemaNameStr] = convertedSchema
75
    }
76

77
    // Load each schema
78
    for schemaNameStr := range jsonCompatibleSchemas {
79
        // Create a schema document with the full swagger context for $ref resolution
80
        completeSchemaDoc := map[string]interface{}{
81
            "$schema": "http://json-schema.org/draft-07/schema#",
82
            "components": map[string]interface{}{
83
                "schemas": jsonCompatibleSchemas,
84
            },
85
            "$ref": "#/components/schemas/" + schemaNameStr,
86
        }
87

88
        schemaBytes, err := json.Marshal(completeSchemaDoc)
89
        if err != nil {
90
            fmt.Printf("Warning: failed to marshal schema %s: %v\n", schemaNameStr, err)
91
            continue
92
        }
93

94
        // Load the schema
95
        schemaLoader := gojsonschema.NewBytesLoader(schemaBytes)
96
        schema, err := gojsonschema.NewSchema(schemaLoader)
97
        if err != nil {
98
            fmt.Printf("Warning: failed to load schema %s: %v\n", schemaNameStr, err)
99
            continue
100
        }
101

102
        sl.schemas[schemaNameStr] = schema
103
        fmt.Printf("â Loaded schema: %s\n", schemaNameStr)
104
    }
105

106
    return nil
107
}
108

109
// getKeys returns the keys of a map
110
func getKeys(m map[string]interface{}) []string {
111
    keys := make([]string, 0, len(m))
112
    for k := range m {
113
        keys = append(keys, k)
114
    }
115
    return keys
116
}
117

118
// getKeysInterface returns the keys of a map with interface{} keys
119
func getKeysInterface(m map[interface{}]interface{}) []string {
120
    keys := make([]string, 0, len(m))
121
    for k := range m {
122
        if keyStr, ok := k.(string); ok {
123
            keys = append(keys, keyStr)
124
        }
125
    }
126
    return keys
127
}
128

129
// convertInterfaceMapToStringMap converts a map[interface{}]interface{} to map[string]interface{}
130
func convertInterfaceMapToStringMap(m map[interface{}]interface{}) map[string]interface{} {
131
    result := make(map[string]interface{})
132
    for k, v := range m {
133
        if keyStr, ok := k.(string); ok {
134
            result[keyStr] = v
135
        }
136
    }
137
    return result
138
}
139

140
// convertToJSONCompatible converts a map[interface{}]interface{} to map[string]interface{}
141
func convertToJSONCompatible(data interface{}) (interface{}, error) {
142
    switch v := data.(type) {
143
    case map[interface{}]interface{}:
144
        result := make(map[string]interface{})
145
        hasNullable := false
146

147
        for k, val := range v {
148
            keyStr, ok := k.(string)
149
            if !ok {
150
                return nil, contextutils.ErrorWithContextf("key is not a string: %v", k)
151
            }
152

153
            // Check for nullable field
154
            if keyStr == "nullable" {
155
                nullable, ok := val.(bool)
156
                if ok && nullable {
157
                    hasNullable = true
158
                    continue // Skip the nullable field as we'll handle it in the type conversion
159
                }
160
            }
161

162
            convertedVal, err := convertToJSONCompatible(val)
163
            if err != nil {
164
                return nil, err
165
            }
166
            result[keyStr] = convertedVal
167
        }
168

169
        // Handle nullable fields by converting to union type
170
        if hasNullable {
171
            // If there's a $ref field, create a union type with null
172
            if ref, hasRef := result["$ref"].(string); hasRef {
173
                // Create a union type that allows both the referenced type and null
174
                result["oneOf"] = []interface{}{
175
                    map[string]interface{}{"$ref": ref},
176
                    map[string]interface{}{"enum": []interface{}{nil}},
177
                }
178
                // Remove the original $ref field
179
                delete(result, "$ref")
180
            } else if typeVal, hasType := result["type"].(string); hasType {
181
                // If there's a type field, convert to array of types including null
182
                result["type"] = []interface{}{typeVal, "null"}
183
            }
184
        }
185

186
        return result, nil
187
    case []interface{}:
188
        result := make([]interface{}, len(v))
189
        for i, val := range v {
190
            convertedVal, err := convertToJSONCompatible(val)
191
            if err != nil {
192
                return nil, err
193
            }
194
            result[i] = convertedVal
195
        }
196
        return result, nil
197
    default:
198
        return data, nil
199
    }
200
}
201

202
// ValidateData validates data against a schema
203
func (sl *SchemaLoader) ValidateData(data interface{}, schemaName string) error {
204
    schema, exists := sl.schemas[schemaName]
205
    if !exists {
206
        return contextutils.ErrorWithContextf("schema %s not found", schemaName)
207
    }
208

209
    // Convert data to JSON
210
    jsonData, err := json.Marshal(data)
211
    if err != nil {
212
        return contextutils.WrapError(err, "failed to marshal data")
213
    }
214

215
    // Create document loader
216
    documentLoader := gojsonschema.NewBytesLoader(jsonData)
217

218
    // Validate
219
    result, err := schema.Validate(documentLoader)
220
    if err != nil {
221
        return contextutils.WrapError(err, "validation error")
222
    }
223

224
    if !result.Valid() {
225
        var validationErrors []string
226
        for _, validationErr := range result.Errors() {
227
            validationErrors = append(validationErrors, fmt.Sprintf("%s: %s", validationErr.Field(), validationErr.Description()))
228
        }
229
        return contextutils.ErrorWithContextf("schema validation failed: %s", strings.Join(validationErrors, "; "))
230
    }
231

232
    return nil
233
}
234

235
// AutoLoadSchemas automatically loads schemas from the swagger file path
236
func AutoLoadSchemas() *SchemaLoader {
237
    loader := NewSchemaLoader()
238

239
    // Get swagger file path from environment variable
240
    swaggerPath := os.Getenv("SWAGGER_FILE_PATH")
241
    if swaggerPath == "" {
242
        fmt.Printf("â SWAGGER_FILE_PATH environment variable not set\n")
243
        return loader
244
    }
245

246
    if _, err := os.Stat(swaggerPath); err == nil {
247
        if err := loader.LoadSchemasFromSwagger(swaggerPath); err != nil {
248
            fmt.Printf("Warning: failed to load schemas from %s: %v\n", swaggerPath, err)
249
        } else {
250
            fmt.Printf("â Successfully loaded schemas from %s\n", swaggerPath)
251
            return loader
252
        }
253
    } else {
254
        fmt.Printf("âï  Swagger file not found at %s: %v\n", swaggerPath, err)
255
    }
256

257
    return loader
258
}
259

260
// IsEndpointDocumented checks if an endpoint is documented in the swagger spec
261
func (sl *SchemaLoader) IsEndpointDocumented(path, method string) bool {
262
    // Get swagger file path from environment variable
263
    swaggerPath := os.Getenv("SWAGGER_FILE_PATH")
264
    if swaggerPath == "" {
265
        return false
266
    }
267

268
    if _, err := os.Stat(swaggerPath); err != nil {
269
        return false
270
    }
271

272
    data, err := os.ReadFile(swaggerPath)
273
    if err != nil {
274
        return false
275
    }
276

277
    var swagger map[string]interface{}
278
    // Parse as YAML
279
    if err := yaml.Unmarshal(data, &swagger); err != nil {
280
        return false
281
    }
282

283
    // Extract paths
284
    paths, ok := swagger["paths"].(map[string]interface{})
285
    if !ok {
286
        // Try with interface{} keys
287
        pathsInterface, ok := swagger["paths"].(map[interface{}]interface{})
288
        if !ok {
289
            return false
290
        }
291
        // Convert to string keys
292
        paths = convertInterfaceMapToStringMap(pathsInterface)
293
    }
294

295
    // First, try exact match
296
    pathInfo, exists := paths[path]
297
    if exists {
298
        pathMap, ok := pathInfo.(map[string]interface{})
299
        if !ok {
300
            // Try with interface{} keys
301
            pathMapInterface, ok := pathInfo.(map[interface{}]interface{})
302
            if !ok {
303
                return false
304
            }
305
            // Convert to string keys
306
            pathMap = convertInterfaceMapToStringMap(pathMapInterface)
307
        }
308

309
        // Look for the specific HTTP method
310
        _, exists = pathMap[strings.ToLower(method)]
311
        if exists {
312
            return true
313
        }
314
    }
315

316
    // If exact match fails, try pattern matching for path parameters
317
    for swaggerPath := range paths {
318
        if sl.pathMatchesPattern(path, swaggerPath) {
319
            pathInfo := paths[swaggerPath]
320
            pathMap, ok := pathInfo.(map[string]interface{})
321
            if !ok {
322
                // Try with interface{} keys
323
                pathMapInterface, ok := pathInfo.(map[interface{}]interface{})
324
                if !ok {
325
                    continue
326
                }
327
                // Convert to string keys
328
                pathMap = convertInterfaceMapToStringMap(pathMapInterface)
329
            }
330

331
            // Look for the specific HTTP method
332
            _, exists = pathMap[strings.ToLower(method)]
333
            if exists {
334
                return true
335
            }
336
        }
337
    }
338

339
    return false
340
}
341

342
// pathMatchesPattern checks if a request path matches a swagger path pattern
343
func (sl *SchemaLoader) pathMatchesPattern(requestPath, swaggerPath string) bool {
344
    // Split paths into segments
345
    requestSegments := strings.Split(requestPath, "/")
346
    swaggerSegments := strings.Split(swaggerPath, "/")
347

348
    // Paths must have the same number of segments
349
    if len(requestSegments) != len(swaggerSegments) {
350
        return false
351
    }
352

353
    // Compare each segment
354
    for i, swaggerSegment := range swaggerSegments {
355
        requestSegment := requestSegments[i]
356

357
        // If swagger segment is a parameter (starts with { and ends with })
358
        if strings.HasPrefix(swaggerSegment, "{") && strings.HasSuffix(swaggerSegment, "}") {
359
            // Any value is acceptable for parameters
360
            continue
361
        }
362

363
        // Otherwise, segments must match exactly
364
        if swaggerSegment != requestSegment {
365
            return false
366
        }
367
    }
368

369
    return true
370
}
371

372
// DetermineRequestSchemaFromPath automatically determines the schema name from the API path and method
373
func (sl *SchemaLoader) DetermineRequestSchemaFromPath(path, method string) string {
374
    // Get swagger file path from environment variable
375
    swaggerPath := os.Getenv("SWAGGER_FILE_PATH")
376
    if swaggerPath == "" {
377
        fmt.Printf("DEBUG: SWAGGER_FILE_PATH not set\n")
378
        return ""
379
    }
380

381
    if _, err := os.Stat(swaggerPath); err != nil {
382
        fmt.Printf("DEBUG: Swagger file not found: %s\n", swaggerPath)
383
        return ""
384
    }
385

386
    data, err := os.ReadFile(swaggerPath)
387
    if err != nil {
388
        fmt.Printf("DEBUG: Failed to read swagger file: %v\n", err)
389
        return ""
390
    }
391

392
    var swagger map[string]interface{}
393
    // Parse as YAML
394
    if err := yaml.Unmarshal(data, &swagger); err != nil {
395
        fmt.Printf("DEBUG: Failed to parse swagger file: %v\n", err)
396
        return ""
397
    }
398

399
    // Extract paths
400
    paths, ok := swagger["paths"].(map[string]interface{})
401
    if !ok {
402
        // Try with interface{} keys
403
        pathsInterface, ok := swagger["paths"].(map[interface{}]interface{})
404
        if !ok {
405
            return ""
406
        }
407
        // Convert to string keys
408
        paths = convertInterfaceMapToStringMap(pathsInterface)
409
    }
410

411
    // Look for the specific path
412
    pathInfo, exists := paths[path]
413
    if !exists {
414
        return ""
415
    }
416

417
    pathMap, ok := pathInfo.(map[string]interface{})
418
    if !ok {
419
        // Try with interface{} keys
420
        pathMapInterface, ok := pathInfo.(map[interface{}]interface{})
421
        if !ok {
422
            return ""
423
        }
424
        // Convert to string keys
425
        pathMap = convertInterfaceMapToStringMap(pathMapInterface)
426
    }
427

428
    // Look for the specific HTTP method
429
    methodInfo, exists := pathMap[strings.ToLower(method)]
430
    if !exists {
431
        return ""
432
    }
433

434
    methodMap, ok := methodInfo.(map[string]interface{})
435
    if !ok {
436
        // Try with interface{} keys
437
        methodMapInterface, ok := methodInfo.(map[interface{}]interface{})
438
        if !ok {
439
            return ""
440
        }
441
        // Convert to string keys
442
        methodMap = convertInterfaceMapToStringMap(methodMapInterface)
443
    }
444

445
    // Extract the request body schema
446
    requestBody, exists := methodMap["requestBody"]
447
    if !exists {
448
        return ""
449
    }
450

451
    requestBodyMap, ok := requestBody.(map[string]interface{})
452
    if !ok {
453
        // Try with interface{} keys
454
        requestBodyMapInterface, ok := requestBody.(map[interface{}]interface{})
455
        if !ok {
456
            return ""
457
        }
458
        // Convert to string keys
459
        requestBodyMap = convertInterfaceMapToStringMap(requestBodyMapInterface)
460
    }
461

462
    // Extract content
463
    content, ok := requestBodyMap["content"].(map[string]interface{})
464
    if !ok {
465
        // Try with interface{} keys
466
        contentInterface, ok := requestBodyMap["content"].(map[interface{}]interface{})
467
        if !ok {
468
            return ""
469
        }
470
        // Convert to string keys
471
        content = convertInterfaceMapToStringMap(contentInterface)
472
    }
473

474
    // Look for application/json content
475
    jsonContent, exists := content["application/json"]
476
    if !exists {
477
        return ""
478
    }
479

480
    jsonContentMap, ok := jsonContent.(map[string]interface{})
481
    if !ok {
482
        // Try with interface{} keys
483
        jsonContentMapInterface, ok := jsonContent.(map[interface{}]interface{})
484
        if !ok {
485
            return ""
486
        }
487
        // Convert to string keys
488
        jsonContentMap = convertInterfaceMapToStringMap(jsonContentMapInterface)
489
    }
490

491
    // Extract schema
492
    schema, exists := jsonContentMap["schema"]
493
    if !exists {
494
        return ""
495
    }
496

497
    schemaMap, ok := schema.(map[string]interface{})
498
    if !ok {
499
        // Try with interface{} keys
500
        schemaMapInterface, ok := schema.(map[interface{}]interface{})
501
        if !ok {
502
            return ""
503
        }
504
        // Convert to string keys
505
        schemaMap = convertInterfaceMapToStringMap(schemaMapInterface)
506
    }
507

508
    // Extract $ref
509
    ref, exists := schemaMap["$ref"]
510
    if !exists {
511
        return ""
512
    }
513

514
    refStr, ok := ref.(string)
515
    if !ok {
516
        return ""
517
    }
518

519
    // Extract schema name from $ref
520
    // $ref format: "#/components/schemas/SchemaName"
521
    parts := strings.Split(refStr, "/")
522
    if len(parts) < 4 {
523
        return ""
524
    }
525

526
    return parts[len(parts)-1]
527
}
528

529
// DetermineSchemaFromPath determines the schema name for a given path and HTTP method
530
// by parsing the swagger file and looking up the response schema for the 200 status code.
531
func (sl *SchemaLoader) DetermineSchemaFromPath(path, method string) string {
532
    // Get swagger file path from environment variable
533
    swaggerPath := os.Getenv("SWAGGER_FILE_PATH")
534
    if swaggerPath == "" {
535
        return ""
536
    }
537

538
    if _, err := os.Stat(swaggerPath); err != nil {
539
        return ""
540
    }
541

542
    data, err := os.ReadFile(swaggerPath)
543
    if err != nil {
544
        return ""
545
    }
546

547
    var swagger map[string]interface{}
548
    // Parse as YAML
549
    if err := yaml.Unmarshal(data, &swagger); err != nil {
550
        return ""
551
    }
552

553
    // Extract paths
554
    paths, ok := swagger["paths"].(map[string]interface{})
555
    if !ok {
556
        // Try with interface{} keys
557
        pathsInterface, ok := swagger["paths"].(map[interface{}]interface{})
558
        if !ok {
559
            return ""
560
        }
561
        // Convert to string keys
562
        paths = convertInterfaceMapToStringMap(pathsInterface)
563
    }
564

565
    // Look for the specific path
566
    pathInfo, exists := paths[path]
567
    if !exists {
568
        return ""
569
    }
570

571
    pathMap, ok := pathInfo.(map[string]interface{})
572
    if !ok {
573
        // Try with interface{} keys
574
        pathMapInterface, ok := pathInfo.(map[interface{}]interface{})
575
        if !ok {
576
            return ""
577
        }
578
        // Convert to string keys
579
        pathMap = convertInterfaceMapToStringMap(pathMapInterface)
580
    }
581

582
    // Look for the specific HTTP method
583
    methodInfo, exists := pathMap[strings.ToLower(method)]
584
    if !exists {
585
        return ""
586
    }
587

588
    methodMap, ok := methodInfo.(map[string]interface{})
589
    if !ok {
590
        // Try with interface{} keys
591
        methodMapInterface, ok := methodInfo.(map[interface{}]interface{})
592
        if !ok {
593
            return ""
594
        }
595
        // Convert to string keys
596
        methodMap = convertInterfaceMapToStringMap(methodMapInterface)
597
    }
598

599
    // Extract the response schema
600
    responses, ok := methodMap["responses"].(map[string]interface{})
601
    if !ok {
602
        // Try with interface{} keys
603
        responsesInterface, ok := methodMap["responses"].(map[interface{}]interface{})
604
        if !ok {
605
            return ""
606
        }
607
        // Convert to string keys
608
        responses = convertInterfaceMapToStringMap(responsesInterface)
609
    }
610

611
    // Look for 200 response
612
    response200, exists := responses["200"]
613
    if !exists {
614
        return ""
615
    }
616

617
    responseMap, ok := response200.(map[string]interface{})
618
    if !ok {
619
        // Try with interface{} keys
620
        responseMapInterface, ok := response200.(map[interface{}]interface{})
621
        if !ok {
622
            return ""
623
        }
624
        // Convert to string keys
625
        responseMap = convertInterfaceMapToStringMap(responseMapInterface)
626
    }
627

628
    // Extract content
629
    content, ok := responseMap["content"].(map[string]interface{})
630
    if !ok {
631
        // Try with interface{} keys
632
        contentInterface, ok := responseMap["content"].(map[interface{}]interface{})
633
        if !ok {
634
            return ""
635
        }
636
        // Convert to string keys
637
        content = convertInterfaceMapToStringMap(contentInterface)
638
    }
639

640
    // Look for application/json
641
    jsonContent, exists := content["application/json"]
642
    if !exists {
643
        return ""
644
    }
645

646
    jsonMap, ok := jsonContent.(map[string]interface{})
647
    if !ok {
648
        // Try with interface{} keys
649
        jsonMapInterface, ok := jsonContent.(map[interface{}]interface{})
650
        if !ok {
651
            return ""
652
        }
653
        // Convert to string keys
654
        jsonMap = convertInterfaceMapToStringMap(jsonMapInterface)
655
    }
656

657
    // Extract schema reference
658
    schema, exists := jsonMap["schema"]
659
    if !exists {
660
        return ""
661
    }
662

663
    schemaMap, ok := schema.(map[string]interface{})
664
    if !ok {
665
        // Try with interface{} keys
666
        schemaMapInterface, ok := schema.(map[interface{}]interface{})
667
        if !ok {
668
            return ""
669
        }
670
        // Convert to string keys
671
        schemaMap = convertInterfaceMapToStringMap(schemaMapInterface)
672
    }
673

674
    // Extract $ref
675
    ref, exists := schemaMap["$ref"]
676
    if !exists {
677
        return ""
678
    }
679

680
    refStr, ok := ref.(string)
681
    if !ok {
682
        return ""
683
    }
684

685
    // Extract schema name from $ref (e.g., "#/components/schemas/DashboardResponse")
686
    if strings.HasPrefix(refStr, "#/components/schemas/") {
687
        schemaName := strings.TrimPrefix(refStr, "#/components/schemas/")
688
        return schemaName
689
    }
690

691
    return ""
692
}
693


			
quizapp internal middleware validation.go
0.0%
Statements
0/100
1
package middleware
2

3
import (
4
    "bytes"
5
    "encoding/json"
6
    "fmt"
7
    "io"
8
    "math"
9
    "net/http"
10
    "strings"
11

12
    "quizapp/internal/observability"
13

14
    "github.com/gin-gonic/gin"
15
)
16

17
// Global schema loader instance
18
var globalSchemaLoader *SchemaLoader
19

20
// initSchemaLoader initializes the global schema loader once
21
func initSchemaLoader() *SchemaLoader {
22
    if globalSchemaLoader == nil {
23
        globalSchemaLoader = AutoLoadSchemas()
24
    }
25
    return globalSchemaLoader
26
}
27

28
// ResponseValidationMiddleware creates middleware that automatically validates responses
29
func ResponseValidationMiddleware(logger *observability.Logger) gin.HandlerFunc {
30
    // Initialize schema loader once
31
    schemaLoader := initSchemaLoader()
32

33
    return func(c *gin.Context) {
34
        // Start tracing span for validation
35
        ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "response_validation")
36
        defer span.End()
37

38
        // Store the original response writer
39
        originalWriter := c.Writer
40

41
        // Create a custom response writer that captures the response
42
        responseWriter := &responseCaptureWriter{
43
            ResponseWriter: originalWriter,
44
            body:           &bytes.Buffer{},
45
            status:         0,
46
        }
47

48
        // Replace the response writer
49
        c.Writer = responseWriter
50

51
        // Continue to the next handler
52
        c.Next()
53

54
        // After the response is written, validate it
55
        statusCode := responseWriter.status
56
        if statusCode == 0 {
57
            statusCode = c.Writer.Status()
58
        }
59

60
        if statusCode == http.StatusOK {
61
            // Try to parse the response as JSON
62
            var responseData interface{}
63
            err := json.Unmarshal(responseWriter.body.Bytes(), &responseData)
64
            if err == nil {
65
                // Automatically determine schema name from the endpoint
66
                schemaName := schemaLoader.DetermineSchemaFromPath(c.Request.URL.Path, c.Request.Method)
67

68
                // Add tracing attributes
69
                span.SetAttributes(
70
                    observability.AttributeSearch(c.Request.URL.Path),
71
                    observability.AttributeTypeFilter(c.Request.Method),
72
                )
73

74
                if schemaName != "" {
75
                    span.SetAttributes(observability.AttributeSearch(schemaName))
76

77
                    if err := schemaLoader.ValidateData(responseData, schemaName); err != nil {
78
                        // Log the validation error and add tracing attributes
79
                        span.SetAttributes(
80
                            observability.AttributeTypeFilter("validation_failed"),
81
                        )
82

83
                        // Log the validation error and fail the request
84
                        logger.Error(ctx, "Response validation failed", err, map[string]interface{}{
85
                            "method":        c.Request.Method,
86
                            "path":          c.Request.URL.Path,
87
                            "schema_name":   schemaName,
88
                            "error":         err.Error(),
89
                            "response_data": responseWriter.body.String()[:int(math.Min(200, float64(responseWriter.body.Len())))],
90
                        })
91

92
                        // Write a 400 error response instead of the original response
93
                        c.Writer = originalWriter
94
                        c.Writer.WriteHeader(http.StatusBadRequest)
95
                        _ = json.NewEncoder(c.Writer).Encode(gin.H{
96
                            "error":   "Response validation failed",
97
                            "message": "API response does not match the specification",
98
                            "method":  c.Request.Method,
99
                            "path":    c.Request.URL.Path,
100
                            "schema":  schemaName,
101
                            "details": err.Error(),
102
                        })
103
                        return
104
                    }
105
                    // Add success tracing attributes
106
                    span.SetAttributes(
107
                        observability.AttributeTypeFilter("validation_passed"),
108
                    )
109

110
                    // Write the buffered response to the real writer
111
                    c.Writer = originalWriter
112
                    c.Writer.WriteHeader(statusCode)
113
                    _, _ = c.Writer.Write(responseWriter.body.Bytes())
114
                    return
115
                }
116
                // No schema found for this endpoint
117
                span.SetAttributes(
118
                    observability.AttributeTypeFilter("no_schema_found"),
119
                )
120

121
                logger.Warn(ctx, "No schema found for endpoint", map[string]interface{}{
122
                    "method": c.Request.Method,
123
                    "path":   c.Request.URL.Path,
124
                })
125
                // Write the buffered response to the real writer
126
                c.Writer = originalWriter
127
                c.Writer.WriteHeader(statusCode)
128
                _, _ = c.Writer.Write(responseWriter.body.Bytes())
129
                return
130
            }
131
            // Failed to parse JSON response
132
            span.SetAttributes(
133
                observability.AttributeTypeFilter("json_parse_failed"),
134
            )
135

136
            logger.Error(ctx, "Failed to parse JSON response", err, map[string]interface{}{
137
                "method": c.Request.Method,
138
                "path":   c.Request.URL.Path,
139
            })
140
            // Write the buffered response to the real writer
141
            c.Writer = originalWriter
142
            c.Writer.WriteHeader(statusCode)
143
            _, _ = c.Writer.Write(responseWriter.body.Bytes())
144
            return
145
        }
146
        // Non-200 status code, skip validation
147
        span.SetAttributes(
148
            observability.AttributeTypeFilter("non_200_status"),
149
        )
150
        // Write the buffered response to the real writer
151
        c.Writer = originalWriter
152
        c.Writer.WriteHeader(statusCode)
153
        _, _ = c.Writer.Write(responseWriter.body.Bytes())
154
    }
155
}
156

157
// responseCaptureWriter captures the response body for validation
158
// Add a status field to track the status code
159
type responseCaptureWriter struct {
160
    gin.ResponseWriter
161
    body   *bytes.Buffer
162
    status int
163
}
164

165
func (w *responseCaptureWriter) WriteHeader(statusCode int) {
166
    w.status = statusCode
167
}
168

169
func (w *responseCaptureWriter) Write(b []byte) (int, error) {
170
    return w.body.Write(b)
171
}
172

173
// isStaticFile checks if a path is a static file that should be allowed to pass through
174
func isStaticFile(path string) bool {
175
    staticPaths := []string{
176
        "/swagger.yaml",
177
        "/swaggerz",
178
        "/configz",
179
        "/",
180
    }
181

182
    for _, staticPath := range staticPaths {
183
        if path == staticPath {
184
            return true
185
        }
186
    }
187

188
    // Also allow paths that start with /backend/ (static assets)
189
    if strings.HasPrefix(path, "/backend/") {
190
        return true
191
    }
192

193
    return false
194
}
195

196
// RequestValidationMiddleware creates middleware that prevents undocumented API calls
197
func RequestValidationMiddleware(logger *observability.Logger) gin.HandlerFunc {
198
    // Initialize schema loader once
199
    schemaLoader := initSchemaLoader()
200

201
    return func(c *gin.Context) {
202
        // Start tracing span for request validation
203
        ctx, span := observability.TraceHandlerFunction(c.Request.Context(), "request_validation")
204
        defer span.End()
205

206
        // Check if the endpoint exists in the swagger spec
207
        path := c.Request.URL.Path
208
        method := c.Request.Method
209

210
        // Log all requests for debugging
211
        logger.Info(ctx, "Request validation middleware called", map[string]interface{}{
212
            "method": method,
213
            "path":   path,
214
        })
215

216
        // Add tracing attributes
217
        span.SetAttributes(
218
            observability.AttributeSearch(path),
219
            observability.AttributeTypeFilter(method),
220
        )
221

222
        // Allow static files to pass through
223
        if isStaticFile(path) {
224
            // Continue to the next handler
225
            c.Next()
226
            return
227
        }
228

229
        // Check if this endpoint is documented in swagger
230
        if !schemaLoader.IsEndpointDocumented(path, method) {
231
            // Log the undocumented API call
232
            logger.Warn(ctx, "Undocumented API call attempted", map[string]interface{}{
233
                "method":     method,
234
                "path":       path,
235
                "ip":         c.ClientIP(),
236
                "user_agent": c.Request.UserAgent(),
237
            })
238

239
            // Return 404 for undocumented endpoints
240
            c.JSON(http.StatusNotFound, gin.H{
241
                "error":   "Endpoint not found",
242
                "message": "The requested endpoint is not documented in the API specification",
243
            })
244
            c.Abort()
245
            return
246
        }
247

248
        // Endpoint is documented, continue
249
        span.SetAttributes(
250
            observability.AttributeTypeFilter("endpoint_documented"),
251
        )
252

253
        // Validate request body against schema for POST/PUT/PATCH requests
254
        if method == "POST" || method == "PUT" || method == "PATCH" {
255
            // Determine the request body schema name for this endpoint
256
            schemaName := schemaLoader.DetermineRequestSchemaFromPath(path, method)
257

258
            // Log the schema determination for debugging
259
            logger.Info(ctx, "Request validation schema determined", map[string]interface{}{
260
                "method":      method,
261
                "path":        path,
262
                "schema_name": schemaName,
263
            })
264

265
            // Log when no schema is found
266
            if schemaName == "" {
267
                logger.Warn(ctx, "No schema found for endpoint", map[string]interface{}{
268
                    "method": method,
269
                    "path":   path,
270
                })
271
            }
272

273
            // Restore the request body so handlers can read it
274
            body, err := c.GetRawData()
275
            if err == nil && len(body) > 0 {
276
                c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
277
            }
278

279
            if schemaName != "" {
280
                // Read the request body without consuming it
281
                body, err := c.GetRawData()
282
                if err == nil && len(body) > 0 {
283
                    // Restore the request body so handlers can read it
284
                    c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
285

286
                    // Log the raw request body for debugging
287
                    logger.Info(ctx, "Request body received", map[string]interface{}{
288
                        "method":      method,
289
                        "path":        path,
290
                        "schema_name": schemaName,
291
                        "body":        string(body),
292
                    })
293

294
                    // Parse the JSON
295
                    var requestData interface{}
296
                    if err := json.Unmarshal(body, &requestData); err == nil {
297
                        // Validate the request data against the schema
298
                        if err := schemaLoader.ValidateData(requestData, schemaName); err != nil {
299
                            // Log the validation error and the request data
300
                            logger.Error(ctx, "Request validation failed", err, map[string]interface{}{
301
                                "method":       method,
302
                                "path":         path,
303
                                "schema_name":  schemaName,
304
                                "error":        err.Error(),
305
                                "request_data": requestData,
306
                                "raw_body":     string(body),
307
                            })
308
                            // Add validation error details to tracing span
309
                            span.SetAttributes(
310
                                observability.AttributeTypeFilter("validation_failed"),
311
                                observability.AttributeSearch(path),
312
                                observability.AttributeTypeFilter(method),
313
                                observability.AttributeTypeFilter(schemaName),
314
                                observability.AttributeTypeFilter("validation_error:"+err.Error()),
315
                                observability.AttributeTypeFilter("request_data:"+fmt.Sprintf("%v", requestData)),
316
                                observability.AttributeTypeFilter("raw_body:"+string(body)),
317
                            )
318
                            // Print a concise summary to stdout for test debug
319
                            fmt.Printf("\n[VALIDATION ERROR] %v\n[REQUEST DATA] %v\n[RAW BODY] %s\n\n", err, requestData, string(body))
320
                            // Return 400 for invalid request data
321
                            c.JSON(http.StatusBadRequest, gin.H{
322
                                "error":   "Invalid request data",
323
                                "message": "Request data does not match the API specification",
324
                                "method":  method,
325
                                "path":    path,
326
                                "schema":  schemaName,
327
                                "details": err.Error(),
328
                            })
329
                            c.Abort()
330
                            return
331
                        }
332
                    }
333

334
                    // Restore the request body so handlers can read it
335
                    c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
336
                }
337
            }
338
        }
339

340
        // Continue to the next handler
341
        c.Next()
342
    }
343
}
344


			
quizapp internal models
100.0%
Statements
27/27
models.go
100.0%
27/27
quizapp internal models models.go
100.0%
Statements
27/27
1
// Package models defines data structures used throughout the quiz application.
2
package models
3

4
import (
5
    "database/sql"
6
    "encoding/json"
7
    "time"
8

9
    "quizapp/internal/api"
10
)
11

12
// User represents a user in the system
13
type User struct {
14
    ID                int            `json:"id" yaml:"id"`
15
    Username          string         `json:"username" yaml:"username"`
16
    Email             sql.NullString `json:"email" yaml:"email"`
17
    Timezone          sql.NullString `json:"timezone" yaml:"timezone"`
18
    PasswordHash      sql.NullString `json:"-" yaml:"-"` // Omit from JSON responses
19
    LastActive        sql.NullTime   `json:"last_active" yaml:"last_active"`
20
    PreferredLanguage sql.NullString `json:"preferred_language" yaml:"preferred_language"`
21
    CurrentLevel      sql.NullString `json:"current_level" yaml:"current_level"`
22
    AIProvider        sql.NullString `json:"ai_provider" yaml:"ai_provider"`
23
    AIModel           sql.NullString `json:"ai_model" yaml:"ai_model"`
24
    AIEnabled         sql.NullBool   `json:"ai_enabled" yaml:"ai_enabled"`
25
    AIAPIKey          sql.NullString `json:"-" yaml:"ai_api_key"` // Omit from JSON responses
26
    CreatedAt         time.Time      `json:"created_at" yaml:"created_at"`
27
    UpdatedAt         time.Time      `json:"updated_at" yaml:"updated_at"`
28
    Roles             []Role         `json:"roles,omitempty" yaml:"roles,omitempty"`
29
}
30

31
// Role represents a role in the system
32
type Role struct {
33
    ID          int       `json:"id" yaml:"id"`
34
    Name        string    `json:"name" yaml:"name"`
35
    Description string    `json:"description" yaml:"description"`
36
    CreatedAt   time.Time `json:"created_at" yaml:"created_at"`
37
    UpdatedAt   time.Time `json:"updated_at" yaml:"updated_at"`
38
}
39

40
// UserRole represents the mapping between users and roles
41
type UserRole struct {
42
    ID        int       `json:"id" yaml:"id"`
43
    UserID    int       `json:"user_id" yaml:"user_id"`
44
    RoleID    int       `json:"role_id" yaml:"role_id"`
45
    CreatedAt time.Time `json:"created_at" yaml:"created_at"`
46
}
47

48
// MarshalJSON customizes JSON marshaling for User to handle sql.NullString and sql.NullTime properly
49
14x
func (u User) MarshalJSON() (result0 []byte, err error) { // Create a struct with the desired JSON structure
50
14x
    return json.Marshal(&struct {
51
14x
        ID                int        `json:"id"`
52
14x
        Username          string     `json:"username"`
53
14x
        Email             *string    `json:"email"`
54
14x
        Timezone          *string    `json:"timezone"`
55
14x
        LastActive        *time.Time `json:"last_active"`
56
14x
        PreferredLanguage *string    `json:"preferred_language"`
57
14x
        CurrentLevel      *string    `json:"current_level"`
58
14x
        AIProvider        *string    `json:"ai_provider"`
59
14x
        AIModel           *string    `json:"ai_model"`
60
14x
        AIEnabled         *bool      `json:"ai_enabled"`
61
14x
        CreatedAt         time.Time  `json:"created_at"`
62
14x
        UpdatedAt         time.Time  `json:"updated_at"`
63
14x
        Roles             []Role     `json:"roles,omitempty"`
64
14x
    }{
65
14x
        ID:                u.ID,
66
14x
        Username:          u.Username,
67
14x
        Email:             nullStringToPointer(u.Email),
68
14x
        Timezone:          nullStringToPointer(u.Timezone),
69
14x
        LastActive:        nullTimeToPointer(u.LastActive),
70
14x
        PreferredLanguage: nullStringToPointer(u.PreferredLanguage),
71
14x
        CurrentLevel:      nullStringToPointer(u.CurrentLevel),
72
14x
        AIProvider:        nullStringToPointer(u.AIProvider),
73
14x
        AIModel:           nullStringToPointer(u.AIModel),
74
14x
        AIEnabled:         nullBoolToPointer(u.AIEnabled),
75
14x
        CreatedAt:         u.CreatedAt,
76
14x
        UpdatedAt:         u.UpdatedAt,
77
14x
        Roles:             u.Roles,
78
14x
    })
79
14x
}
80

81
// Helper functions for converting sql.Null types to pointers
82
98x
func nullStringToPointer(ns sql.NullString) *string {
83
98x
    if ns.Valid {
84
33x
        return &ns.String
85
33x
    }
86
65x
    return nil
87
}
88

89
36x
func nullTimeToPointer(nt sql.NullTime) *time.Time {
90
36x
    if nt.Valid {
91
16x
        return &nt.Time
92
16x
    }
93
20x
    return nil
94
}
95

96
21x
func nullBoolToPointer(nb sql.NullBool) *bool {
97
21x
    if nb.Valid {
98
9x
        return &nb.Bool
99
9x
    }
100
12x
    return nil
101
}
102

103
4x
func nullInt32ToPointer(ni sql.NullInt32) *int32 {
104
4x
    if ni.Valid {
105
2x
        return &ni.Int32
106
2x
    }
107
2x
    return nil
108
}
109

110
// UserAPIKey represents an API key for a specific provider for a user
111
type UserAPIKey struct {
112
    ID        int       `json:"id"`
113
    UserID    int       `json:"user_id"`
114
    Provider  string    `json:"provider"`
115
    APIKey    string    `json:"-"` // Omit from JSON responses for security
116
    CreatedAt time.Time `json:"created_at"`
117
    UpdatedAt time.Time `json:"updated_at"`
118
}
119

120
// Question represents a quiz question
121
type Question struct {
122
    ID              int                    `json:"id" yaml:"id"`
123
    Type            QuestionType           `json:"type" yaml:"type"`
124
    Language        string                 `json:"language" yaml:"language"`
125
    Level           string                 `json:"level" yaml:"level"`
126
    DifficultyScore float64                `json:"difficulty_score" yaml:"difficulty_score"`
127
    Content         map[string]interface{} `json:"content" yaml:"content"`
128
    CorrectAnswer   int                    `json:"correct_answer" yaml:"correct_answer"`
129
    Explanation     string                 `json:"explanation,omitempty" yaml:"explanation"`
130
    CreatedAt       time.Time              `json:"created_at" yaml:"created_at"`
131
    Status          QuestionStatus         `json:"status" yaml:"status"`
132
    // Test data field for specifying which users should have this question
133
    Users []string `json:"users,omitempty" yaml:"users,omitempty"`
134
    // Variety elements for question generation diversity
135
    TopicCategory      string `json:"topic_category,omitempty" yaml:"topic_category"`
136
    GrammarFocus       string `json:"grammar_focus,omitempty" yaml:"grammar_focus"`
137
    VocabularyDomain   string `json:"vocabulary_domain,omitempty" yaml:"vocabulary_domain"`
138
    Scenario           string `json:"scenario,omitempty" yaml:"scenario"`
139
    StyleModifier      string `json:"style_modifier,omitempty" yaml:"style_modifier"`
140
    DifficultyModifier string `json:"difficulty_modifier,omitempty" yaml:"difficulty_modifier"`
141
    TimeContext        string `json:"time_context,omitempty" yaml:"time_context"`
142
}
143

144
// UserQuestion represents the mapping between users and questions
145
type UserQuestion struct {
146
    ID         int       `json:"id"`
147
    UserID     int       `json:"user_id"`
148
    QuestionID int       `json:"question_id"`
149
    CreatedAt  time.Time `json:"created_at"`
150
}
151

152
// QuestionReport represents a report of a question by a user
153
type QuestionReport struct {
154
    ID               int       `json:"id"`
155
    QuestionID       int       `json:"question_id"`
156
    ReportedByUserID int       `json:"reported_by_user_id"`
157
    ReportReason     string    `json:"report_reason"`
158
    CreatedAt        time.Time `json:"created_at"`
159
}
160

161
// QuestionType represents the type of question
162
type QuestionType string
163

164
// QuestionStatus represents the status of a question
165
type QuestionStatus string
166

167
const (
168
    // QuestionStatusActive is for questions that are in active use
169
    QuestionStatusActive QuestionStatus = "active"
170
    // QuestionStatusReported is for questions that have been reported as incorrect
171
    QuestionStatusReported QuestionStatus = "reported"
172
)
173

174
// Question types supported by the system
175
const (
176
    // Vocabulary represents vocabulary in context questions
177
    Vocabulary QuestionType = "vocabulary"
178
    // FillInBlank represents fill-in-the-blank questions
179
    FillInBlank QuestionType = "fill_blank"
180
    // QuestionAnswer represents simple Q&A questions
181
    QuestionAnswer QuestionType = "qa"
182
    // ReadingComprehension represents reading comprehension questions
183
    ReadingComprehension QuestionType = "reading_comprehension"
184
)
185

186
// UserResponse represents a user's answer to a question
187
type UserResponse struct {
188
    ID              int           `json:"id" yaml:"id"`
189
    UserID          int           `json:"user_id" yaml:"user_id"`
190
    QuestionID      int           `json:"question_id" yaml:"question_id"`
191
    UserAnswerIndex int           `json:"user_answer_index" yaml:"user_answer_index"`
192
    IsCorrect       bool          `json:"is_correct" yaml:"is_correct"`
193
    ResponseTimeMs  int           `json:"response_time_ms" yaml:"response_time_ms"`
194
    ConfidenceLevel sql.NullInt32 `json:"confidence_level" yaml:"confidence_level"`
195
    CreatedAt       time.Time     `json:"created_at" yaml:"created_at"`
196
}
197

198
// MarshalJSON customizes JSON marshaling for UserResponse to handle sql.NullInt32 properly
199
2x
func (ur UserResponse) MarshalJSON() (result0 []byte, err error) {
200
2x
    return json.Marshal(&struct {
201
2x
        ID              int       `json:"id"`
202
2x
        UserID          int       `json:"user_id"`
203
2x
        QuestionID      int       `json:"question_id"`
204
2x
        UserAnswerIndex int       `json:"user_answer_index"`
205
2x
        IsCorrect       bool      `json:"is_correct"`
206
2x
        ResponseTimeMs  int       `json:"response_time_ms"`
207
2x
        ConfidenceLevel *int32    `json:"confidence_level"`
208
2x
        CreatedAt       time.Time `json:"created_at"`
209
2x
    }{
210
2x
        ID:              ur.ID,
211
2x
        UserID:          ur.UserID,
212
2x
        QuestionID:      ur.QuestionID,
213
2x
        UserAnswerIndex: ur.UserAnswerIndex,
214
2x
        IsCorrect:       ur.IsCorrect,
215
2x
        ResponseTimeMs:  ur.ResponseTimeMs,
216
2x
        ConfidenceLevel: nullInt32ToPointer(ur.ConfidenceLevel),
217
2x
        CreatedAt:       ur.CreatedAt,
218
2x
    })
219
2x
}
220

221
// PerformanceMetrics tracks user performance across different categories
222
type PerformanceMetrics struct {
223
    ID                    int       `json:"id"`
224
    UserID                int       `json:"user_id"`
225
    Topic                 string    `json:"topic"`
226
    Language              string    `json:"language"`
227
    Level                 string    `json:"level"`
228
    TotalAttempts         int       `json:"total_attempts"`
229
    CorrectAttempts       int       `json:"correct_attempts"`
230
    AverageResponseTimeMs float64   `json:"average_response_time_ms"`
231
    DifficultyAdjustment  float64   `json:"difficulty_adjustment"`
232
    LastUpdated           time.Time `json:"last_updated"`
233
}
234

235
// AccuracyRate calculates the accuracy percentage
236
5x
func (pm *PerformanceMetrics) AccuracyRate() float64 {
237
5x
    if pm.TotalAttempts == 0 {
238
1x
        return 0.0
239
1x
    }
240
4x
    return float64(pm.CorrectAttempts) / float64(pm.TotalAttempts) * 100
241
}
242

243
// QuestionRequest represents a request for a new question
244
type QuestionRequest struct {
245
    UserID       int          `json:"user_id"`
246
    Language     string       `json:"language"`
247
    Level        string       `json:"level"`
248
    QuestionType QuestionType `json:"question_type,omitempty"`
249
}
250

251
// AnswerRequest represents a user's answer submission
252
type AnswerRequest struct {
253
    QuestionID     int    `json:"question_id"`
254
    UserAnswer     string `json:"user_answer"`
255
    ResponseTimeMs int    `json:"response_time_ms"`
256
}
257

258
// AnswerResponse represents the response to an answer submission
259
type AnswerResponse struct {
260
    IsCorrect      bool   `json:"is_correct"`
261
    CorrectAnswer  string `json:"correct_answer"`
262
    UserAnswer     string `json:"user_answer"`
263
    Explanation    string `json:"explanation"`
264
    NextDifficulty string `json:"next_difficulty,omitempty"`
265
}
266

267
// GetCorrectAnswerText returns the text of the correct answer from the question content
268
22x
func (q *Question) GetCorrectAnswerText() string {
269
22x
    if optionsRaw, ok := q.Content["options"]; ok {
270
17x
        if options, ok := optionsRaw.([]interface{}); ok {
271
15x
            if q.CorrectAnswer >= 0 && q.CorrectAnswer < len(options) {
272
12x
                if optStr, ok := options[q.CorrectAnswer].(string); ok {
273
12x
                    return optStr
274
12x
                }
275
            }
276
        }
277
    }
278
10x
    return ""
279
}
280

281
// UserSettings represents user preference settings
282
type UserSettings struct {
283
    Language   string `json:"language" yaml:"language"`
284
    Level      string `json:"level" yaml:"level"`
285
    AIProvider string `json:"ai_provider" yaml:"ai_provider"`
286
    AIModel    string `json:"ai_model" yaml:"ai_model"`
287
    AIEnabled  bool   `json:"ai_enabled" yaml:"ai_enabled"`
288
    AIAPIKey   string `json:"api_key" yaml:"ai_api_key"`
289
}
290

291
// UserLearningPreferences represents user learning preferences and settings
292
type UserLearningPreferences struct {
293
    ID                        int      `json:"id" db:"id"`
294
    UserID                    int      `json:"user_id" db:"user_id"`
295
    PreferredLanguage         string   `json:"preferred_language" db:"preferred_language"`
296
    CurrentLevel              string   `json:"current_level" db:"current_level"`
297
    AIProvider                string   `json:"ai_provider" db:"ai_provider"`
298
    AIModel                   string   `json:"ai_model" db:"ai_model"`
299
    AIEnabled                 bool     `json:"ai_enabled" db:"ai_enabled"`
300
    AIAPIKey                  string   `json:"-" db:"ai_api_key"` // Omit from JSON for security
301
    DailyGoal                 int      `json:"daily_goal" db:"daily_goal"`
302
    WeeklyGoal                int      `json:"weekly_goal" db:"weekly_goal"`
303
    PreferredQuestionType     string   `json:"preferred_question_type" db:"preferred_question_type"`
304
    PreferredQuestionTypes    []string `json:"preferred_question_types" db:"preferred_question_types"`
305
    PreferredDifficultyLevel  string   `json:"preferred_difficulty_level" db:"preferred_difficulty_level"`
306
    PreferredTopics           []string `json:"preferred_topics" db:"preferred_topics"`
307
    PreferredQuestionCount    int      `json:"preferred_question_count" db:"preferred_question_count"`
308
    SpacedRepetitionEnabled   bool     `json:"spaced_repetition_enabled" db:"spaced_repetition_enabled"`
309
    AdaptiveDifficultyEnabled bool     `json:"adaptive_difficulty_enabled" db:"adaptive_difficulty_enabled"`
310
    FocusOnWeakAreas          bool     `json:"focus_on_weak_areas" db:"focus_on_weak_areas"`
311
    IncludeReviewQuestions    bool     `json:"include_review_questions" db:"include_review_questions"`
312
    FreshQuestionRatio        float64  `json:"fresh_question_ratio" db:"fresh_question_ratio"`
313
    KnownQuestionPenalty      float64  `json:"known_question_penalty" db:"known_question_penalty"`
314
    ReviewIntervalDays        int      `json:"review_interval_days" db:"review_interval_days"`
315
    WeakAreaBoost             float64  `json:"weak_area_boost" db:"weak_area_boost"`
316
    StudyTime                 string   `json:"study_time" db:"study_time"`
317
    DailyReminderEnabled      bool     `json:"daily_reminder_enabled" db:"daily_reminder_enabled"`
318
    // Preferred TTS voice (e.g., it-IT-IsabellaNeural)
319
    TTSVoice              string     `json:"tts_voice" db:"tts_voice"`
320
    LastDailyReminderSent *time.Time `json:"last_daily_reminder_sent" db:"last_daily_reminder_sent"`
321
    CreatedAt             time.Time  `json:"created_at" db:"created_at"`
322
    UpdatedAt             time.Time  `json:"updated_at" db:"updated_at"`
323
}
324

325
// UserProgress represents a user's overall progress
326
type UserProgress struct {
327
    CurrentLevel       string                         `json:"current_level"`
328
    TotalQuestions     int                            `json:"total_questions"`
329
    CorrectAnswers     int                            `json:"correct_answers"`
330
    AccuracyRate       float64                        `json:"accuracy_rate"`
331
    PerformanceByTopic map[string]*PerformanceMetrics `json:"performance_by_topic"`
332
    WeakAreas          []string                       `json:"weak_areas"`
333
    RecentActivity     []UserResponse                 `json:"recent_activity"`
334
    SuggestedLevel     string                         `json:"suggested_level,omitempty"`
335
}
336

337
// AIQuestionGenRequest represents a request to the AI service for question generation
338
type AIQuestionGenRequest struct {
339
    Language              string       `json:"language"`
340
    Level                 string       `json:"level"`
341
    QuestionType          QuestionType `json:"question_type"`
342
    Count                 int          `json:"count"`
343
    RecentQuestionHistory []string     `json:"-"` // Don't include in JSON, internal use
344
}
345

346
// AIChatRequest represents a request to the AI service for a new chat feature
347
type AIChatRequest struct {
348
    Language              string
349
    Level                 string
350
    QuestionType          QuestionType // Question type for context
351
    Question              string
352
    Options               []string
353
    Passage               string // For reading comprehension
354
    UserAnswer            string // Optional
355
    CorrectAnswer         string // Optional
356
    IsCorrect             *bool  // Optional
357
    UserMessage           string
358
    ConversationHistory   []ChatMessage `json:"conversation_history,omitempty"`
359
    RecentQuestionHistory []string      `json:"-"` // Don't include in JSON, internal use
360
}
361

362
// ChatMessage represents a single message in the chat conversation
363
type ChatMessage struct {
364
    Role    api.ChatMessageRole `json:"role"`    // "user" or "assistant"
365
    Content string              `json:"content"` // The message content
366
}
367

368
// AIExplanationRequest represents a request for an explanation of a wrong answer
369
type AIExplanationRequest struct {
370
    Question      string `json:"question"`
371
    UserAnswer    string `json:"user_answer"`
372
    CorrectAnswer string `json:"correct_answer"`
373
    Language      string `json:"language"`
374
    Level         string `json:"level"`
375
}
376

377
// MarshalContentToJSON serializes the question content to JSON string
378
10x
func (q *Question) MarshalContentToJSON() (result0 string, err error) {
379
10x
    data, err := json.Marshal(q.Content)
380
10x
    return string(data), err
381
10x
}
382

383
// UnmarshalContentFromJSON deserializes JSON string into question content
384
12x
func (q *Question) UnmarshalContentFromJSON(data string) error {
385
12x
    return json.Unmarshal([]byte(data), &q.Content)
386
12x
}
387

388
// WorkerSettings represents worker configuration settings stored in database
389
type WorkerSettings struct {
390
    ID           int       `json:"id" db:"id"`
391
    SettingKey   string    `json:"setting_key" db:"setting_key"`
392
    SettingValue string    `json:"setting_value" db:"setting_value"`
393
    CreatedAt    time.Time `json:"created_at" db:"created_at"`
394
    UpdatedAt    time.Time `json:"updated_at" db:"updated_at"`
395
}
396

397
// WorkerStatus represents worker health and activity status
398
type WorkerStatus struct {
399
    ID                      int            `json:"id" db:"id"`
400
    WorkerInstance          string         `json:"worker_instance" db:"worker_instance"`
401
    IsRunning               bool           `json:"is_running" db:"is_running"`
402
    IsPaused                bool           `json:"is_paused" db:"is_paused"`
403
    CurrentActivity         sql.NullString `json:"current_activity" db:"current_activity"`
404
    LastHeartbeat           sql.NullTime   `json:"last_heartbeat" db:"last_heartbeat"`
405
    LastRunStart            sql.NullTime   `json:"last_run_start" db:"last_run_start"`
406
    LastRunEnd              sql.NullTime   `json:"last_run_end" db:"last_run_end"`
407
    LastRunFinish           sql.NullTime   `json:"last_run_finish" db:"last_run_finish"`
408
    LastRunError            sql.NullString `json:"last_run_error" db:"last_run_error"`
409
    TotalQuestionsProcessed int            `json:"total_questions_processed" db:"total_questions_processed"`
410
    TotalQuestionsGenerated int            `json:"total_questions_generated" db:"total_questions_generated"`
411
    TotalRuns               int            `json:"total_runs" db:"total_runs"`
412
    CreatedAt               time.Time      `json:"created_at" db:"created_at"`
413
    UpdatedAt               time.Time      `json:"updated_at" db:"updated_at"`
414
}
415

416
// MarshalJSON customizes JSON marshaling for WorkerStatus to handle sql.NullString and sql.NullTime properly
417
2x
func (ws WorkerStatus) MarshalJSON() (result0 []byte, err error) {
418
2x
    return json.Marshal(&struct {
419
2x
        ID                      int        `json:"id"`
420
2x
        WorkerInstance          string     `json:"worker_instance"`
421
2x
        IsRunning               bool       `json:"is_running"`
422
2x
        IsPaused                bool       `json:"is_paused"`
423
2x
        CurrentActivity         *string    `json:"current_activity"`
424
2x
        LastHeartbeat           *time.Time `json:"last_heartbeat"`
425
2x
        LastRunStart            *time.Time `json:"last_run_start"`
426
2x
        LastRunEnd              *time.Time `json:"last_run_end"`
427
2x
        LastRunFinish           *time.Time `json:"last_run_finish"`
428
2x
        LastRunError            *string    `json:"last_run_error"`
429
2x
        TotalQuestionsProcessed int        `json:"total_questions_processed"`
430
2x
        TotalQuestionsGenerated int        `json:"total_questions_generated"`
431
2x
        TotalRuns               int        `json:"total_runs"`
432
2x
        CreatedAt               time.Time  `json:"created_at"`
433
2x
        UpdatedAt               time.Time  `json:"updated_at"`
434
2x
    }{
435
2x
        ID:                      ws.ID,
436
2x
        WorkerInstance:          ws.WorkerInstance,
437
2x
        IsRunning:               ws.IsRunning,
438
2x
        IsPaused:                ws.IsPaused,
439
2x
        CurrentActivity:         nullStringToPointer(ws.CurrentActivity),
440
2x
        LastHeartbeat:           nullTimeToPointer(ws.LastHeartbeat),
441
2x
        LastRunStart:            nullTimeToPointer(ws.LastRunStart),
442
2x
        LastRunEnd:              nullTimeToPointer(ws.LastRunEnd),
443
2x
        LastRunFinish:           nullTimeToPointer(ws.LastRunFinish),
444
2x
        LastRunError:            nullStringToPointer(ws.LastRunError),
445
2x
        TotalQuestionsProcessed: ws.TotalQuestionsProcessed,
446
2x
        TotalQuestionsGenerated: ws.TotalQuestionsGenerated,
447
2x
        TotalRuns:               ws.TotalRuns,
448
2x
        CreatedAt:               ws.CreatedAt,
449
2x
        UpdatedAt:               ws.UpdatedAt,
450
2x
    })
451
2x
}
452


			
quizapp internal observability
52.6%
Statements
113/215
global_tracer.go
2.5%
1/40
logging.go
67.2%
41/61
metrics.go
54.2%
13/24
middleware.go
61.9%
26/42
setup.go
88.9%
16/18
span_helpers.go
0.0%
0/6
tracing.go
66.7%
16/24
quizapp internal observability tracing.go
2.5%
Statements
1/40
1
package observability
2

3
import (
4
    "context"
5
    "fmt"
6

7
    "quizapp/internal/models"
8

9
    "go.opentelemetry.io/otel"
10
    "go.opentelemetry.io/otel/attribute"
11
    "go.opentelemetry.io/otel/trace"
12
)
13

14
var globalTracer trace.Tracer
15

16
// InitGlobalTracer initializes the global tracer for the application.
17
1x
func InitGlobalTracer() {
18
1x
    globalTracer = otel.Tracer("quiz-app")
19
1x
}
20

21
// GetGlobalTracer returns the global tracer instance for the application.
22
func GetGlobalTracer() trace.Tracer {
23
    if globalTracer == nil {
24
        // Fallback to default tracer if not initialized
25
        globalTracer = otel.Tracer("quiz-app")
26
    }
27
    return globalTracer
28
}
29

30
// TraceFunction starts a new span with a descriptive name for the given service and function.
31
func TraceFunction(ctx context.Context, serviceName, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
32
    tracer := GetGlobalTracer()
33
    spanName := fmt.Sprintf("%s.%s", serviceName, functionName)
34
    return tracer.Start(ctx, spanName, trace.WithAttributes(attributes...))
35
}
36

37
// TraceFunctionWithErrorHandling starts a new span and automatically adds error attributes if the function panics or returns an error.
38
func TraceFunctionWithErrorHandling(ctx context.Context, serviceName, functionName string, fn func() error, attributes ...attribute.KeyValue) error {
39
    _, span := TraceFunction(ctx, serviceName, functionName, attributes...)
40
    defer func() {
41
        if err := recover(); err != nil {
42
            span.SetAttributes(
43
                attribute.Bool("error", true),
44
                attribute.String("error.type", "panic"),
45
                attribute.String("error.message", fmt.Sprintf("%v", err)),
46
            )
47
            span.End()
48
            panic(err) // re-panic
49
        }
50
    }()
51

52
    err := fn()
53
    if err != nil {
54
        span.SetAttributes(
55
            attribute.Bool("error", true),
56
            attribute.String("error.message", err.Error()),
57
        )
58
    }
59
    span.End()
60
    return err
61
}
62

63
// TraceAIFunction starts a new span for an AI service function.
64
func TraceAIFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
65
    return TraceFunction(ctx, "ai", functionName, attributes...)
66
}
67

68
// TraceUserFunction starts a new span for a user service function.
69
func TraceUserFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
70
    return TraceFunction(ctx, "user", functionName, attributes...)
71
}
72

73
// TraceQuestionFunction starts a new span for a question service function.
74
func TraceQuestionFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
75
    return TraceFunction(ctx, "question", functionName, attributes...)
76
}
77

78
// TraceWorkerFunction starts a new span for a worker service function.
79
func TraceWorkerFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
80
    return TraceFunction(ctx, "worker", functionName, attributes...)
81
}
82

83
// TraceLearningFunction starts a new span for a learning service function.
84
func TraceLearningFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
85
    return TraceFunction(ctx, "learning", functionName, attributes...)
86
}
87

88
// TraceHandlerFunction starts a new span for a handler function.
89
func TraceHandlerFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
90
    return TraceFunction(ctx, "handler", functionName, attributes...)
91
}
92

93
// TraceVarietyFunction starts a new span for a variety service function.
94
func TraceVarietyFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
95
    return TraceFunction(ctx, "variety", functionName, attributes...)
96
}
97

98
// TraceOAuthFunction starts a new span for an OAuth service function.
99
func TraceOAuthFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
100
    return TraceFunction(ctx, "oauth", functionName, attributes...)
101
}
102

103
// TraceCleanupFunction starts a new span for a cleanup service function.
104
func TraceCleanupFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
105
    return TraceFunction(ctx, "cleanup", functionName, attributes...)
106
}
107

108
// TraceDatabaseFunction starts a new span for a database function.
109
func TraceDatabaseFunction(ctx context.Context, functionName string, attributes ...attribute.KeyValue) (context.Context, trace.Span) {
110
    return TraceFunction(ctx, "database", functionName, attributes...)
111
}
112

113
// AttributeQuestion returns a tracing attribute for a question's ID.
114
func AttributeQuestion(q *models.Question) attribute.KeyValue {
115
    return attribute.String("question.id", fmt.Sprintf("%d", q.ID))
116
}
117

118
// AttributeQuestionID returns a tracing attribute for a question ID.
119
func AttributeQuestionID(id int) attribute.KeyValue {
120
    return attribute.Int("question.id", id)
121
}
122

123
// AttributeUserID returns a tracing attribute for a user ID.
124
func AttributeUserID(id int) attribute.KeyValue {
125
    return attribute.Int("user.id", id)
126
}
127

128
// AttributeLanguage returns a tracing attribute for a language.
129
func AttributeLanguage(lang string) attribute.KeyValue {
130
    return attribute.String("language", lang)
131
}
132

133
// AttributeLevel returns a tracing attribute for a level.
134
func AttributeLevel(level string) attribute.KeyValue {
135
    return attribute.String("level", level)
136
}
137

138
// AttributeQuestionType returns a tracing attribute for a question type.
139
func AttributeQuestionType(qType interface{}) attribute.KeyValue {
140
    return attribute.String("question.type", fmt.Sprintf("%v", qType))
141
}
142

143
// AttributeLimit returns a tracing attribute for a limit value.
144
func AttributeLimit(limit int) attribute.KeyValue {
145
    return attribute.Int("limit", limit)
146
}
147

148
// AttributePage returns a tracing attribute for a page value.
149
func AttributePage(page int) attribute.KeyValue {
150
    return attribute.Int("page", page)
151
}
152

153
// AttributePageSize returns a tracing attribute for a page size value.
154
func AttributePageSize(size int) attribute.KeyValue {
155
    return attribute.Int("page_size", size)
156
}
157

158
// AttributeSearch returns a tracing attribute for a search value.
159
func AttributeSearch(search string) attribute.KeyValue {
160
    return attribute.String("search", search)
161
}
162

163
// AttributeTypeFilter returns a tracing attribute for a type filter value.
164
func AttributeTypeFilter(typeFilter string) attribute.KeyValue {
165
    return attribute.String("type_filter", typeFilter)
166
}
167

168
// AttributeStatusFilter returns a tracing attribute for a status filter value.
169
func AttributeStatusFilter(statusFilter string) attribute.KeyValue {
170
    return attribute.String("status_filter", statusFilter)
171
}
172


			
quizapp internal observability tracing.go
67.2%
Statements
41/61
1
// Package observability provides OpenTelemetry tracing, metrics, and structured logging
2
// with trace correlation for the quiz application.
3
package observability
4

5
import (
6
    "context"
7
    "os"
8

9
    "quizapp/internal/config"
10

11
    "go.opentelemetry.io/contrib/bridges/otelzap"
12
    "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
13
    "go.opentelemetry.io/otel/sdk/log"
14
    "go.uber.org/zap"
15
    "go.uber.org/zap/zapcore"
16
)
17

18
// Logger wraps the zap logger with OpenTelemetry context support
19
type Logger struct {
20
    *zap.Logger
21
}
22

23
// NewLogger creates a new logger with OpenTelemetry context support and OTLP export
24
3x
func NewLogger(cfg *config.OpenTelemetryConfig) *Logger {
25
3x
    return NewLoggerWithLevel(cfg, zap.InfoLevel)
26
3x
}
27

28
// NewLoggerWithLevel creates a new logger with OpenTelemetry context support and OTLP export
29
3x
func NewLoggerWithLevel(cfg *config.OpenTelemetryConfig, level zapcore.Level) *Logger {
30
3x
    // If logging is disabled, return a no-op logger
31
3x
    if cfg == nil || !cfg.EnableLogging {
32
1x
        return &Logger{Logger: zap.NewNop()}
33
1x
    }
34

35
    // Create a basic zap logger for stdout
36
2x
    zapConfig := zap.NewProductionConfig()
37
2x
    zapConfig.Level = zap.NewAtomicLevelAt(level)
38
2x
    zapConfig.EncoderConfig.TimeKey = "timestamp"
39
2x
    zapConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
40
2x
    zapConfig.EncoderConfig.StacktraceKey = "stacktrace"
41
2x

42
2x
    // Use development config if in development mode
43
2x
    if os.Getenv("ENV") == "development" {
44
        zapConfig = zap.NewDevelopmentConfig()
45
        zapConfig.Level = zap.NewAtomicLevelAt(level)
46
    }
47

48
2x
    zapLogger, err := zapConfig.Build()
49
2x
    if err != nil {
50
        // Fallback to a basic logger if config fails
51
        zapLogger = zap.NewExample()
52
    }
53

54
    // If OTLP logging is enabled, set up the OTLP exporter
55
2x
    if cfg.EnableLogging && cfg.Endpoint != "" {
56
1x
        // Log that we're attempting to set up OTLP export
57
1x
        zapLogger.Info("Setting up OTLP logging", zap.String("endpoint", cfg.Endpoint), zap.String("protocol", cfg.Protocol))
58
1x

59
1x
        // Create OTLP exporter with proper endpoint format
60
1x
        endpoint := cfg.Endpoint
61
1x

62
1x
        exporter, err := otlploggrpc.New(context.Background(),
63
1x
            otlploggrpc.WithEndpoint(endpoint),
64
1x
            otlploggrpc.WithInsecure(),
65
1x
        )
66
1x
        if err != nil {
67
            // Log the error but continue with stdout logging
68
            zapLogger.Error("Failed to create OTLP exporter", zap.Error(err), zap.String("endpoint", endpoint))
69
        } else {
70
1x
            zapLogger.Info("Successfully created OTLP exporter", zap.String("endpoint", endpoint))
71
1x

72
1x
            // Create batch processor
73
1x
            processor := log.NewBatchProcessor(exporter)
74
1x

75
1x
            // Create logger provider
76
1x
            provider := log.NewLoggerProvider(
77
1x
                log.WithProcessor(processor),
78
1x
            )
79
1x

80
1x
            // Create OpenTelemetry core
81
1x
            otelCore := otelzap.NewCore("quizapp", otelzap.WithLoggerProvider(provider))
82
1x

83
1x
            // Create a new zap logger with both stdout and OTLP cores
84
1x
            cores := []zapcore.Core{
85
1x
                zapLogger.Core(),
86
1x
                otelCore,
87
1x
            }
88
1x

89
1x
            // Create a new logger with multiple cores
90
1x
            multiCore := zapcore.NewTee(cores...)
91
1x
            zapLogger = zap.New(multiCore)
92
1x

93
1x
            zapLogger.Info("OTLP logging successfully configured", zap.String("endpoint", endpoint))
94
1x
        }
95
1x
    } else {
96
1x
        zapLogger.Info("OTLP logging not enabled", zap.Bool("enable_logging", cfg.EnableLogging), zap.String("endpoint", cfg.Endpoint))
97
1x
    }
98

99
2x
    return &Logger{Logger: zapLogger}
100
}
101

102
// Debug logs a debug message with context
103
func (l *Logger) Debug(ctx context.Context, msg string, fields ...map[string]interface{}) {
104
    l.logWithContext(ctx, zap.DebugLevel, msg, fields...)
105
}
106

107
// Info logs an info message with context
108
2x
func (l *Logger) Info(ctx context.Context, msg string, fields ...map[string]interface{}) {
109
2x
    l.logWithContext(ctx, zap.InfoLevel, msg, fields...)
110
2x
}
111

112
// Warn logs a warning message with context
113
func (l *Logger) Warn(ctx context.Context, msg string, fields ...map[string]interface{}) {
114
    l.logWithContext(ctx, zap.WarnLevel, msg, fields...)
115
}
116

117
// Error logs an error message with context
118
1x
func (l *Logger) Error(ctx context.Context, msg string, err error, fields ...map[string]interface{}) {
119
1x
    // Merge fields with error information
120
1x
    allFields := l.mergeFields(fields...)
121
1x
    if err != nil {
122
        allFields["error"] = err.Error()
123
    }
124
1x
    l.logWithContext(ctx, zap.ErrorLevel, msg, allFields)
125
}
126

127
// logWithContext logs a message with OpenTelemetry context correlation
128
3x
func (l *Logger) logWithContext(_ context.Context, level zapcore.Level, msg string, fields ...map[string]interface{}) {
129
3x
    // Merge all fields into a single map
130
3x
    allFields := l.mergeFields(fields...)
131
3x

132
3x
    // Convert fields to zap fields
133
3x
    zapFields := make([]zap.Field, 0, len(allFields))
134
3x
    for k, v := range allFields {
135
        zapFields = append(zapFields, zap.Any(k, v))
136
    }
137

138
    // Log with the appropriate level
139
3x
    switch level {
140
    case zap.DebugLevel:
141
        l.Logger.Debug(msg, zapFields...)
142
2x
    case zap.InfoLevel:
143
2x
        l.Logger.Info(msg, zapFields...)
144
    case zap.WarnLevel:
145
        l.Logger.Warn(msg, zapFields...)
146
1x
    case zap.ErrorLevel:
147
1x
        l.Logger.Error(msg, zapFields...)
148
    default:
149
        l.Logger.Info(msg, zapFields...)
150
    }
151
}
152

153
// mergeFields merges multiple field maps into a single map
154
4x
func (l *Logger) mergeFields(fields ...map[string]interface{}) map[string]interface{} {
155
4x
    if len(fields) == 0 {
156
3x
        return map[string]interface{}{}
157
3x
    }
158

159
1x
    if len(fields) == 1 {
160
1x
        // Handle nil field map
161
1x
        if fields[0] == nil {
162
            return map[string]interface{}{}
163
        }
164
1x
        return fields[0]
165
    }
166

167
    // Merge multiple field maps
168
    merged := make(map[string]interface{})
169
    for _, fieldMap := range fields {
170
        // Skip nil field maps
171
        if fieldMap == nil {
172
            continue
173
        }
174
        for k, v := range fieldMap {
175
            merged[k] = v
176
        }
177
    }
178
    return merged
179
}
180

181
// Sync flushes any buffered log entries
182
func (l *Logger) Sync() error {
183
    return l.Logger.Sync()
184
}
185


			
quizapp internal observability tracing.go
54.2%
Statements
13/24
1
package observability
2

3
import (
4
    "context"
5

6
    "quizapp/internal/config"
7
    contextutils "quizapp/internal/utils"
8

9
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
10
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
11
    "go.opentelemetry.io/otel/sdk/metric"
12
    "go.opentelemetry.io/otel/sdk/resource"
13
    semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
14
)
15

16
// InitMetrics initializes OpenTelemetry metrics
17
1x
func InitMetrics(cfg *config.OpenTelemetryConfig) (result0 *metric.MeterProvider, err error) {
18
1x
    ctx := context.Background()
19
1x

20
1x
    // Set up resource attributes
21
1x
    res, err := resource.New(ctx,
22
1x
        resource.WithAttributes(
23
1x
            semconv.ServiceName(cfg.ServiceName),
24
1x
            semconv.ServiceVersion(cfg.ServiceVersion),
25
1x
        ),
26
1x
    )
27
1x
    if err != nil {
28
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otel resource: %w", err)
29
    }
30

31
    // Set up exporter
32
1x
    var exporter metric.Exporter
33
1x
    switch cfg.Protocol {
34
1x
    case "grpc":
35
1x
        // For gRPC, strip http:// prefix if present, otherwise use endpoint as-is
36
1x
        endpoint := cfg.Endpoint
37
1x
        exp, err := otlpmetricgrpc.New(ctx,
38
1x
            otlpmetricgrpc.WithEndpoint(endpoint),
39
1x
            func() otlpmetricgrpc.Option {
40
1x
                if cfg.Insecure {
41
1x
                    return otlpmetricgrpc.WithInsecure()
42
1x
                }
43
                return nil
44
            }(),
45
            otlpmetricgrpc.WithHeaders(cfg.Headers),
46
        )
47
1x
        if err != nil {
48
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otlp grpc metric exporter: %w", err)
49
        }
50
1x
        exporter = exp
51
    case "http":
52
        exp, err := otlpmetrichttp.New(ctx,
53
            otlpmetrichttp.WithEndpoint(cfg.Endpoint),
54
            func() otlpmetrichttp.Option {
55
                if cfg.Insecure {
56
                    return otlpmetrichttp.WithInsecure()
57
                }
58
                return nil
59
            }(),
60
            otlpmetrichttp.WithHeaders(cfg.Headers),
61
        )
62
        if err != nil {
63
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otlp http metric exporter: %w", err)
64
        }
65
        exporter = exp
66
    default:
67
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "unsupported otel protocol: %s", cfg.Protocol)
68
    }
69

70
    // Set up meter provider
71
1x
    mp := metric.NewMeterProvider(
72
1x
        metric.WithReader(metric.NewPeriodicReader(exporter)),
73
1x
        metric.WithResource(res),
74
1x
    )
75
1x
    return mp, nil
76
}
77


			
quizapp internal observability tracing.go
61.9%
Statements
26/42
1
package observability
2

3
import (
4
    "errors"
5

6
    "github.com/gin-contrib/sessions"
7
    "github.com/gin-gonic/gin"
8
    "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
9
    "go.opentelemetry.io/otel/attribute"
10
    "go.opentelemetry.io/otel/codes"
11
    "go.opentelemetry.io/otel/trace"
12

13
    contextutils "quizapp/internal/utils"
14
)
15

16
// GinMiddleware creates OpenTelemetry middleware for Gin HTTP requests
17
3x
func GinMiddleware(serviceName string) gin.HandlerFunc {
18
3x
    return otelgin.Middleware(serviceName)
19
3x
}
20

21
// GinMiddlewareWithErrorHandling creates OpenTelemetry middleware with automatic error attribute addition and detailed logging
22
3x
func GinMiddlewareWithErrorHandling(serviceName string) gin.HandlerFunc {
23
3x
    return func(c *gin.Context) {
24
9x
        // Use the existing OpenTelemetry middleware
25
9x
        otelgin.Middleware(serviceName)(c)
26
9x

27
9x
        // After the request is processed, check for errors
28
9x
        c.Next()
29
9x

30
9x
        // Get the span from context and add error attributes for failed requests
31
9x
        if span := trace.SpanFromContext(c.Request.Context()); span != nil {
32
9x
            statusCode := c.Writer.Status()
33
9x
            if statusCode >= 400 {
34
6x
                // Determine error severity based on status code and error types
35
6x
                severity := determineErrorSeverity(statusCode, c.Errors)
36
6x

37
6x
                // Create a more descriptive error message based on status code
38
6x
                var errorMsg string
39
6x
                switch {
40
2x
                case statusCode >= 500:
41
2x
                    errorMsg = "server error"
42
4x
                case statusCode >= 400:
43
4x
                    errorMsg = "client error"
44
                default:
45
                    errorMsg = "request failed"
46
                }
47

48
                // Add error details from Gin's error context if available
49
6x
                if len(c.Errors) > 0 {
50
                    for _, err := range c.Errors {
51
                        if appErr, ok := err.Err.(*contextutils.AppError); ok {
52
                            errorMsg = appErr.Message
53
                            severity = string(appErr.Severity)
54
                            break
55
                        }
56
                        errorMsg = err.Error()
57
                    }
58
                }
59

60
                // Record the error with stack trace
61
6x
                span.RecordError(errors.New(errorMsg), trace.WithStackTrace(true))
62
6x
                span.SetStatus(codes.Error, errorMsg)
63
6x

64
6x
                // Add additional attributes for better debugging
65
6x
                span.SetAttributes(
66
6x
                    attribute.Int("http.status_code", statusCode),
67
6x
                    attribute.String("http.method", c.Request.Method),
68
6x
                    attribute.String("http.path", c.Request.URL.Path),
69
6x
                    attribute.String("error.handler", c.HandlerName()),
70
6x
                    attribute.String("error.severity", severity),
71
6x
                )
72
6x

73
6x
                // Add user context if available
74
6x
                session := sessions.Default(c)
75
6x
                if userID, ok := session.Get("user_id").(int); ok {
76
                    span.SetAttributes(attribute.Int("error.user_id", userID))
77
                }
78

79
                // Add request body size for debugging
80
6x
                if c.Request.ContentLength > 0 {
81
                    span.SetAttributes(attribute.Int64("error.request_size", c.Request.ContentLength))
82
                }
83

84
                // Add specific error attributes based on error types
85
6x
                if len(c.Errors) > 0 {
86
                    for _, err := range c.Errors {
87
                        if appErr, ok := err.Err.(*contextutils.AppError); ok {
88
                            span.SetAttributes(
89
                                attribute.String("error.code", string(appErr.Code)),
90
                                attribute.Bool("error.retryable", contextutils.IsRetryable(appErr)),
91
                            )
92
                            break
93
                        }
94
                    }
95
                }
96

97
                // Add server error specific attributes
98
6x
                if statusCode >= 500 {
99
2x
                    span.SetAttributes(
100
2x
                        attribute.Bool("error.server_error", true),
101
2x
                    )
102
2x
                }
103
            }
104
        }
105
    }
106
}
107

108
// determineErrorSeverity determines the severity level based on status code and error types
109
6x
func determineErrorSeverity(statusCode int, errors []*gin.Error) string {
110
6x
    // Check for AppError types first
111
6x
    for _, err := range errors {
112
        if appErr, ok := err.Err.(*contextutils.AppError); ok {
113
            return string(appErr.Severity)
114
        }
115
    }
116

117
    // Fallback to status code based severity
118
6x
    switch {
119
2x
    case statusCode >= 500:
120
2x
        return string(contextutils.SeverityError)
121
4x
    case statusCode >= 400:
122
4x
        return string(contextutils.SeverityWarn)
123
    default:
124
        return string(contextutils.SeverityInfo)
125
    }
126
}
127


			
quizapp internal observability tracing.go
88.9%
Statements
16/18
1
package observability
2

3
import (
4
    "quizapp/internal/config"
5

6
    "go.opentelemetry.io/otel/sdk/metric"
7
    "go.opentelemetry.io/otel/sdk/trace"
8
)
9

10
// SetupObservability initializes tracing, metrics, and logging for a service
11
2x
func SetupObservability(cfg *config.OpenTelemetryConfig, serviceName string) (result0 *trace.TracerProvider, result1 *metric.MeterProvider, result2 *Logger, err error) {
12
2x
    if serviceName != "" {
13
2x
        cfg.ServiceName = serviceName
14
2x
    }
15

16
2x
    var tp *trace.TracerProvider
17
2x
    var mp *metric.MeterProvider
18
2x
    var logger *Logger
19
2x

20
2x
    if cfg.EnableTracing {
21
1x
        tp, err = InitTracing(cfg)
22
1x
        if err != nil {
23
            return nil, nil, nil, err
24
        }
25
        // Initialize the global tracer
26
1x
        InitGlobalTracer()
27
    }
28

29
2x
    if cfg.EnableMetrics {
30
1x
        mp, err = InitMetrics(cfg)
31
1x
        if err != nil {
32
            return tp, nil, nil, err
33
        }
34
    }
35

36
2x
    if cfg.EnableLogging {
37
1x
        logger = NewLogger(cfg)
38
1x
    } else {
39
1x
        // Return a no-op logger when logging is disabled
40
1x
        logger = NewLogger(&config.OpenTelemetryConfig{EnableLogging: false})
41
1x
    }
42

43
2x
    return tp, mp, logger, nil
44
}
45


			
quizapp internal observability tracing.go
0.0%
Statements
0/6
1
package observability
2

3
import (
4
    "go.opentelemetry.io/otel/codes"
5
    "go.opentelemetry.io/otel/trace"
6
)
7

8
// FinishSpan ends a span and records any error pointed to by errPtr.
9
// Use with a named error return: `defer observability.FinishSpan(span, &err)`
10
func FinishSpan(span trace.Span, errPtr *error) {
11
    if span == nil {
12
        return
13
    }
14
    if errPtr != nil && *errPtr != nil {
15
        span.RecordError(*errPtr, trace.WithStackTrace(true))
16
        span.SetStatus(codes.Error, (*errPtr).Error())
17
    }
18
    span.End()
19
}
20


			
quizapp internal observability tracing.go
66.7%
Statements
16/24
1
package observability
2

3
import (
4
    "context"
5

6
    "quizapp/internal/config"
7
    contextutils "quizapp/internal/utils"
8

9
    "go.opentelemetry.io/otel"
10
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
11
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
12
    "go.opentelemetry.io/otel/propagation"
13
    "go.opentelemetry.io/otel/sdk/resource"
14
    "go.opentelemetry.io/otel/sdk/trace"
15
    semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
16
)
17

18
// InitTracing initializes OpenTelemetry tracing
19
1x
func InitTracing(cfg *config.OpenTelemetryConfig) (result0 *trace.TracerProvider, err error) {
20
1x
    ctx := context.Background()
21
1x

22
1x
    // Set up resource attributes
23
1x
    res, err := resource.New(ctx,
24
1x
        resource.WithAttributes(
25
1x
            semconv.ServiceName(cfg.ServiceName),
26
1x
            semconv.ServiceVersion(cfg.ServiceVersion),
27
1x
        ),
28
1x
    )
29
1x
    if err != nil {
30
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otel resource: %w", err)
31
    }
32

33
    // Set up exporter
34
1x
    var exporter trace.SpanExporter
35
1x
    switch cfg.Protocol {
36
1x
    case "grpc":
37
1x
        // For gRPC, strip http:// prefix if present, otherwise use endpoint as-is
38
1x
        endpoint := cfg.Endpoint
39
1x
        exp, err := otlptracegrpc.New(ctx,
40
1x
            otlptracegrpc.WithEndpoint(endpoint),
41
1x
            func() otlptracegrpc.Option {
42
1x
                if cfg.Insecure {
43
1x
                    return otlptracegrpc.WithInsecure()
44
1x
                }
45
                return nil
46
            }(),
47
            otlptracegrpc.WithHeaders(cfg.Headers),
48
        )
49
1x
        if err != nil {
50
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otlp grpc exporter: %w", err)
51
        }
52
1x
        exporter = exp
53
    case "http":
54
        exp, err := otlptracehttp.New(ctx,
55
            otlptracehttp.WithEndpoint(cfg.Endpoint),
56
            otlptracehttp.WithInsecure(),
57
            otlptracehttp.WithHeaders(cfg.Headers),
58
        )
59
        if err != nil {
60
            return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to create otlp http exporter: %w", err)
61
        }
62
        exporter = exp
63
    default:
64
        return nil, contextutils.WrapErrorf(contextutils.ErrInternalError, "unsupported otel protocol: %s", cfg.Protocol)
65
    }
66

67
    // Set up sampler
68
1x
    sampler := trace.ParentBased(trace.TraceIDRatioBased(cfg.SamplingRate))
69
1x

70
1x
    // Set up tracer provider
71
1x
    tp := trace.NewTracerProvider(
72
1x
        trace.WithBatcher(exporter),
73
1x
        trace.WithResource(res),
74
1x
        trace.WithSampler(sampler),
75
1x
    )
76
1x
    otel.SetTracerProvider(tp)
77
1x

78
1x
    // Set up text map propagator for trace context propagation
79
1x
    // This enables the backend to receive and process trace headers from NGINX
80
1x
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
81
1x
        propagation.TraceContext{},
82
1x
        propagation.Baggage{},
83
1x
    ))
84
1x

85
1x
    return tp, nil
86
}
87


			
quizapp internal services
62.9%
Statements
3263/5190
ai_service.go
64.6%
553/856
ai_service_templates.go
78.6%
11/14
cleanup_service.go
26.2%
27/103
daily_question_service.go
62.2%
265/426
email_factory.go
90.9%
10/11
email_service.go
42.7%
35/82
generation_hint_service.go
83.3%
25/30
learning_service.go
76.2%
581/762
no_questions_error.go
50.0%
1/2
oauth_service.go
62.2%
84/135
question_service.go
72.2%
866/1199
test_email_service.go
63.9%
23/36
test_utils.go
66.7%
26/39
user_service.go
63.4%
472/744
variety_service.go
83.2%
89/107
worker_service.go
30.3%
195/644
quizapp internal services worker_service.go
64.6%
Statements
553/856
1
// Package services provides business logic services for the quiz application.
2
package services
3

4
import (
5
    "bufio"
6
    "bytes"
7
    "context"
8
    "encoding/json"
9
    "fmt"
10
    "io"
11
    "net/http"
12
    "runtime/debug"
13
    "strconv"
14
    "strings"
15
    "sync"
16
    "time"
17

18
    "quizapp/internal/config"
19
    "quizapp/internal/models"
20
    "quizapp/internal/observability"
21
    contextutils "quizapp/internal/utils"
22

23
    "github.com/xeipuuv/gojsonschema"
24
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
25
    "go.opentelemetry.io/otel/attribute"
26
    "go.opentelemetry.io/otel/codes"
27
    "go.opentelemetry.io/otel/trace"
28
)
29

30
// JSON Schema definitions for grammar field
31
// These schemas are used with the 'grammar' field in OpenAI-compatible API requests
32
// to enforce specific JSON structure validation. This ensures that AI models return
33
// exactly the expected format, eliminating parsing errors and improving reliability.
34
//
35
// The grammar field is conditionally included based on provider support (see supportsGrammarField).
36
// Providers that don't support grammar (like Google) will fall back to prompt-based structure guidance.
37
const (
38
    // Single-item schemas for ai-fix (single question objects)
39
    SingleQuestionSchema = `{
40
        "type": "object",
41
        "properties": {
42
            "question": {"type": "string"},
43
            "options": {"type": "array", "items": {"type": "string"}, "minItems": 4, "maxItems": 4},
44
            "correct_answer": {"type": "integer"},
45
            "explanation": {"type": "string"},
46
            "topic": {"type": "string"}
47
        },
48
        "required": ["question", "options", "correct_answer", "explanation"]
49
    }`
50

51
    SingleReadingComprehensionSchema = `{
52
        "type": "object",
53
        "properties": {
54
            "passage": {"type": "string"},
55
            "question": {"type": "string"},
56
            "options": {"type": "array", "items": {"type": "string"}, "minItems": 4, "maxItems": 4},
57
            "correct_answer": {"type": "integer"},
58
            "explanation": {"type": "string"},
59
            "topic": {"type": "string"}
60
        },
61
        "required": ["passage", "question", "options", "correct_answer", "explanation"]
62
    }`
63

64
    SingleVocabularyQuestionSchema = `{
65
        "type": "object",
66
        "properties": {
67
            "sentence": {"type": "string"},
68
            "question": {"type": "string"},
69
            "options": {"type": "array", "items": {"type": "string"}, "minItems": 4, "maxItems": 4},
70
            "correct_answer": {"type": "integer"},
71
            "explanation": {"type": "string"},
72
            "topic": {"type": "string"}
73
        },
74
        "required": ["sentence", "question", "options", "correct_answer", "explanation"]
75
    }`
76
)
77

78
var (
79
    // BatchQuestionsSchema is a batch wrapper around SingleQuestionSchema.
80
    BatchQuestionsSchema = fmt.Sprintf(`{"type":"array","items":%s}`, SingleQuestionSchema)
81

82
    // BatchReadingComprehensionSchema is a batch wrapper around SingleReadingComprehensionSchema.
83
    BatchReadingComprehensionSchema = fmt.Sprintf(`{"type":"array","items":%s}`, SingleReadingComprehensionSchema)
84

85
    // BatchVocabularyQuestionSchema is a batch wrapper around SingleVocabularyQuestionSchema.
86
    BatchVocabularyQuestionSchema = fmt.Sprintf(`{"type":"array","items":%s}`, SingleVocabularyQuestionSchema)
87
)
88

89
// UserAIConfig holds per-user AI configuration
90
type UserAIConfig struct {
91
    Provider string
92
    Model    string
93
    APIKey   string
94
    Username string // For logging purposes
95
}
96

97
// AIServiceInterface defines the interface for AI-powered question generation
98
type AIServiceInterface interface {
99
    GenerateQuestion(ctx context.Context, userConfig *UserAIConfig, req *models.AIQuestionGenRequest) (*models.Question, error)
100
    GenerateQuestions(ctx context.Context, userConfig *UserAIConfig, req *models.AIQuestionGenRequest) ([]*models.Question, error)
101
    GenerateQuestionsStream(ctx context.Context, userConfig *UserAIConfig, req *models.AIQuestionGenRequest, progress chan<- *models.Question, variety *VarietyElements) error
102
    GenerateChatResponse(ctx context.Context, userConfig *UserAIConfig, req *models.AIChatRequest) (string, error)
103
    GenerateChatResponseStream(ctx context.Context, userConfig *UserAIConfig, req *models.AIChatRequest, chunks chan<- string) error
104
    TestConnection(ctx context.Context, provider, model, apiKey string) error
105
    GetConcurrencyStats() ConcurrencyStats
106
    GetQuestionBatchSize(provider string) int
107
    VarietyService() *VarietyService
108

109
    // TemplateManager exposes template rendering and example loading for prompts
110
    TemplateManager() *AITemplateManager
111

112
    // SupportsGrammarField reports whether the provider supports the grammar field
113
    SupportsGrammarField(provider string) bool
114

115
    // CallWithPrompt sends a raw prompt (and optional grammar) to the provider and returns the response
116
    CallWithPrompt(ctx context.Context, userConfig *UserAIConfig, prompt, grammar string) (string, error)
117
    Shutdown(ctx context.Context) error
118
}
119

120
// ConcurrencyStats provides metrics about AI request concurrency
121
type ConcurrencyStats struct {
122
    ActiveRequests  int            `json:"active_requests"`
123
    MaxConcurrent   int            `json:"max_concurrent"`
124
    QueuedRequests  int            `json:"queued_requests"`
125
    TotalRequests   int64          `json:"total_requests"`
126
    UserActiveCount map[string]int `json:"user_active_count"`
127
    MaxPerUser      int            `json:"max_per_user"`
128
}
129

130
// AIService provides AI-powered question generation using OpenAI-compatible APIs
131
type AIService struct {
132
    httpClient *http.Client
133
    debug      bool
134
    cfg        *config.Config
135

136
    // Template management
137
    templateManager *AITemplateManager
138

139
    // Variety service for question diversity
140
    varietyService *VarietyService
141

142
    // Concurrency control
143
    globalSemaphore chan struct{} // Limits total concurrent requests
144
    maxConcurrent   int           // Maximum concurrent requests globally
145
    maxPerUser      int           // Maximum concurrent requests per user
146

147
    // Per-user concurrency tracking
148
    userRequestCount map[string]int // Username -> active request count
149
    concurrencyMu    sync.RWMutex   // Protects user maps
150

151
    // Metrics
152
    totalRequests  int64        // Total requests processed
153
    activeRequests int          // Current active requests
154
    statsMu        sync.RWMutex // Protects stats
155

156
    // Observability
157
    logger *observability.Logger
158

159
    // Shutdown control
160
    shutdownCtx context.Context
161
    shutdownMu  sync.RWMutex
162
}
163

164
// Schema validation counters
165
var (
166
    SchemaValidationFailures       = make(map[models.QuestionType]int)
167
    SchemaValidationFailureDetails = make(map[models.QuestionType][]string) // NEW: error details
168
    SchemaValidationMu             sync.Mutex
169
)
170

171
// extractItemsSchema extracts the items schema from a batch schema
172
2021x
func extractItemsSchema(batchSchema string) (result0 string, err error) {
173
2021x
    var schemaMap map[string]interface{}
174
2021x
    if err = json.Unmarshal([]byte(batchSchema), &schemaMap); err != nil {
175
        return "", err
176
    }
177
    // For batch schemas, extract the items schema
178
2021x
    if items, ok := schemaMap["items"]; ok {
179
2021x
        var itemsBytes []byte
180
2021x
        itemsBytes, err = json.Marshal(items)
181
2021x
        if err != nil {
182
            return "", err
183
        }
184
2021x
        return string(itemsBytes), nil
185
    }
186
    return "", contextutils.ErrorWithContextf("no items found in batch schema")
187
}
188

189
// ValidateQuestionSchema validates a question against the appropriate schema
190
2013x
func (s *AIService) ValidateQuestionSchema(ctx context.Context, qType models.QuestionType, question interface{}) (result0 bool, err error) {
191
2013x
    _, span := observability.TraceAIFunction(ctx, "validate_question_schema",
192
2013x
        observability.AttributeQuestionType(qType),
193
2013x
    )
194
2013x
    defer observability.FinishSpan(span, &err)
195
2013x

196
2013x
    // Validate input parameters
197
2013x
    if question == nil {
198
        span.SetAttributes(attribute.String("validation.result", "nil_question"))
199
        return false, contextutils.ErrorWithContextf("question cannot be nil")
200
    }
201

202
2013x
    var schema string
203
2013x
    switch qType {
204
2011x
    case models.Vocabulary:
205
2011x
        schema = BatchVocabularyQuestionSchema
206
1x
    case models.ReadingComprehension:
207
1x
        schema = BatchReadingComprehensionSchema
208
    case models.FillInBlank, models.QuestionAnswer:
209
        schema = BatchQuestionsSchema
210
    default:
211
        span.SetAttributes(attribute.String("validation.result", "unknown_type"))
212
        return false, contextutils.ErrorWithContextf("unknown question type: %v", qType)
213
    }
214

215
    // Extract the items schema for validation
216
2013x
    itemSchema, err := extractItemsSchema(schema)
217
2013x
    if err != nil {
218
        span.SetAttributes(attribute.String("validation.result", "schema_extract_error"), attribute.String("validation.error", err.Error()))
219
        return false, contextutils.WrapErrorf(err, "failed to extract schema for question type %v", qType)
220
    }
221

222
    // Marshal the question to JSON
223
    // If question is a *models.Question, validate only Content
224
2013x
    toValidate := question
225
2013x
    if q, ok := question.(*models.Question); ok {
226
2013x
        if q == nil {
227
            span.SetAttributes(attribute.String("validation.result", "nil_question_model"))
228
            return false, contextutils.ErrorWithContextf("question model is nil")
229
        }
230
2013x
        toValidate = q.Content
231
    }
232

233
2013x
    questionBytes, err := json.Marshal(toValidate)
234
2013x
    if err != nil {
235
        span.SetAttributes(attribute.String("validation.result", "marshal_error"), attribute.String("validation.error", err.Error()))
236
        return false, contextutils.WrapErrorf(err, "failed to marshal question for validation")
237
    }
238

239
    // Validate
240
2013x
    result, err := gojsonschema.Validate(
241
2013x
        gojsonschema.NewStringLoader(itemSchema),
242
2013x
        gojsonschema.NewBytesLoader(questionBytes),
243
2013x
    )
244
2013x
    if err != nil {
245
        span.SetAttributes(attribute.String("validation.result", "validate_error"), attribute.String("validation.error", err.Error()))
246
        return false, contextutils.WrapErrorf(err, "schema validation failed for question type %v", qType)
247
    }
248

249
2013x
    if !result.Valid() {
250
        errs := result.Errors()
251
        var errorMessages []string
252
        for _, e := range errs {
253
            errorMessages = append(errorMessages, e.String())
254
        }
255
        span.SetAttributes(attribute.String("validation.result", "invalid"))
256
        return false, contextutils.ErrorWithContextf("question failed schema validation: %s", strings.Join(errorMessages, "; "))
257
    }
258

259
2013x
    span.SetAttributes(attribute.String("validation.result", "valid"))
260
2013x
    return true, nil
261
}
262

263
// NewAIService creates a new AI service instance
264
58x
func NewAIService(cfg *config.Config, logger *observability.Logger) *AIService {
265
58x
    // Create template manager
266
58x
    templateManager, err := NewAITemplateManager()
267
58x
    if err != nil {
268
        logger.Error(context.Background(), "Failed to create template manager", err, map[string]interface{}{})
269
        panic(err) // Use panic for fatal errors in initialization
270
    }
271

272
    // Create variety service
273
58x
    varietyService := NewVarietyServiceWithLogger(cfg, logger)
274
58x

275
58x
    // Create instrumented HTTP client with reasonable timeouts and explicit span options
276
58x
    httpClient := &http.Client{
277
58x
        Timeout: config.AIRequestTimeout,
278
58x
        Transport: otelhttp.NewTransport(http.DefaultTransport,
279
58x
            otelhttp.WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)),
280
58x
        ),
281
58x
    }
282
58x

283
58x
    // Get concurrency limits from config
284
58x
    maxConcurrent := cfg.Server.MaxAIConcurrent
285
58x
    maxPerUser := cfg.Server.MaxAIPerUser
286
58x

287
58x
    // Create global semaphore for limiting concurrent requests
288
58x
    globalSemaphore := make(chan struct{}, maxConcurrent)
289
58x

290
58x
    service := &AIService{
291
58x
        httpClient:       httpClient,
292
58x
        debug:            cfg.Server.Debug,
293
58x
        cfg:              cfg,
294
58x
        templateManager:  templateManager,
295
58x
        varietyService:   varietyService,
296
58x
        globalSemaphore:  globalSemaphore,
297
58x
        maxConcurrent:    maxConcurrent,
298
58x
        maxPerUser:       maxPerUser,
299
58x
        userRequestCount: make(map[string]int),
300
58x
        shutdownCtx:      context.Background(),
301
58x
        logger:           logger,
302
58x
    }
303
58x

304
58x
    return service
305
}
306

307
// Shutdown gracefully shuts down the AI service and cleans up resources
308
6x
func (s *AIService) Shutdown(ctx context.Context) error {
309
6x
    s.shutdownMu.Lock()
310
6x
    defer s.shutdownMu.Unlock()
311
6x

312
6x
    // Create a new shutdown context
313
6x
    shutdownCtx, cancel := context.WithCancel(ctx)
314
6x
    s.shutdownCtx = shutdownCtx
315
6x
    defer cancel()
316
6x

317
6x
    // Wait for all active requests to complete with timeout
318
6x
    timeout := config.AIShutdownTimeout
319
6x
    if deadline, ok := ctx.Deadline(); ok {
320
1x
        timeout = time.Until(deadline)
321
1x
    }
322

323
    // Wait for active requests to complete
324
6x
    ticker := time.NewTicker(config.AIShutdownPollInterval)
325
6x
    defer ticker.Stop()
326
6x

327
6x
    for i := 0; i < int(timeout/config.AIShutdownPollInterval); i++ {
328
6x
        s.statsMu.RLock()
329
6x
        active := s.activeRequests
330
6x
        s.statsMu.RUnlock()
331
6x

332
6x
        if active == 0 {
333
6x
            break
334
        }
335

336
        select {
337
        case <-ticker.C:
338
            continue
339
        case <-ctx.Done():
340
            return ctx.Err()
341
        }
342
    }
343

344
    // Close the HTTP client
345
6x
    if s.httpClient != nil {
346
6x
        s.httpClient.CloseIdleConnections()
347
6x
    }
348

349
    // Clean up user request counts
350
6x
    s.concurrencyMu.Lock()
351
6x
    s.userRequestCount = make(map[string]int)
352
6x
    s.concurrencyMu.Unlock()
353
6x

354
6x
    s.logger.Info(ctx, "AI Service shutdown completed")
355
6x
    return nil
356
}
357

358
// isShutdown checks if the service is shutting down
359
15x
func (s *AIService) isShutdown() bool {
360
15x
    s.shutdownMu.RLock()
361
15x
    defer s.shutdownMu.RUnlock()
362
15x
    select {
363
3x
    case <-s.shutdownCtx.Done():
364
3x
        return true
365
9x
    default:
366
9x
        return false
367
    }
368
}
369

370
// OpenAIRequest represents a request to the OpenAI-compatible API
371
type OpenAIRequest struct {
372
    Model       string    `json:"model"`
373
    Messages    []Message `json:"messages"`
374
    Temperature float64   `json:"temperature"`
375
    MaxTokens   int       `json:"max_tokens"`
376
    Grammar     string    `json:"grammar,omitempty"`
377
    Stream      bool      `json:"stream,omitempty"`
378
}
379

380
// Message represents a chat message in the API request
381
type Message struct {
382
    Role    string `json:"role"`
383
    Content string `json:"content"`
384
}
385

386
// OpenAIResponse represents a response from the OpenAI-compatible API
387
type OpenAIResponse struct {
388
    Choices []Choice  `json:"choices"`
389
    Error   *APIError `json:"error,omitempty"`
390
}
391

392
// Choice represents a choice in the API response
393
type Choice struct {
394
    Message Message `json:"message"`
395
}
396

397
// APIError represents an error response from the API
398
type APIError struct {
399
    Message string `json:"message"`
400
    Type    string `json:"type"`
401
}
402

403
// OpenAIStreamResponse represents a streaming response chunk from the OpenAI-compatible API
404
type OpenAIStreamResponse struct {
405
    Choices []StreamChoice `json:"choices"`
406
    Error   *APIError      `json:"error,omitempty"`
407
}
408

409
// StreamChoice represents a choice in the streaming API response
410
type StreamChoice struct {
411
    Delta        StreamDelta `json:"delta"`
412
    FinishReason *string     `json:"finish_reason"`
413
}
414

415
// StreamDelta represents the delta content in a streaming response
416
type StreamDelta struct {
417
    Content string `json:"content"`
418
}
419

420
// getGrammarSchema returns the appropriate JSON schema for the given question type
421
30x
func getGrammarSchema(questionType models.QuestionType) string {
422
30x
    // Always return the batch schema for each type
423
30x
    switch questionType {
424
3x
    case models.ReadingComprehension:
425
3x
        return BatchReadingComprehensionSchema
426
21x
    case models.Vocabulary:
427
21x
        return BatchVocabularyQuestionSchema
428
3x
    case models.FillInBlank:
429
3x
        return BatchQuestionsSchema
430
3x
    case models.QuestionAnswer:
431
3x
        return BatchQuestionsSchema
432
    }
433
    // Fallback for unknown types
434
    return BatchQuestionsSchema
435
}
436

437
// GetFixSchema returns the single-item JSON schema for ai-fix or an error if unsupported.
438
func GetFixSchema(questionType models.QuestionType) (string, error) {
439
    switch questionType {
440
    case models.ReadingComprehension:
441
        return SingleReadingComprehensionSchema, nil
442
    case models.Vocabulary:
443
        return SingleVocabularyQuestionSchema, nil
444
    case models.FillInBlank, models.QuestionAnswer:
445
        return SingleQuestionSchema, nil
446
    default:
447
        return "", contextutils.WrapErrorf(contextutils.ErrAIConfigInvalid, "no schema for question type: %v", questionType)
448
    }
449
}
450

451
// addJSONStructureGuidance appends JSON structure requirements to prompts for providers that don't support grammar
452
7x
func (s *AIService) addJSONStructureGuidance(prompt string, questionType models.QuestionType) string {
453
7x
    // Get the schema for this question type
454
7x
    schema := getGrammarSchema(questionType)
455
7x

456
7x
    data := AITemplateData{
457
7x
        SchemaForPrompt: schema,
458
7x
    }
459
7x

460
7x
    guidance, err := s.templateManager.RenderTemplate(JSONStructureGuidanceTemplate, data)
461
7x
    if err != nil {
462
        s.logger.Error(context.Background(), "Failed to render JSON structure guidance template", err, map[string]interface{}{})
463
        panic(err)
464
    }
465

466
7x
    return prompt + guidance
467
}
468

469
// GenerateQuestion generates a single question using AI
470
3x
func (s *AIService) GenerateQuestion(ctx context.Context, userConfig *UserAIConfig, req *models.AIQuestionGenRequest) (result0 *models.Question, err error) {
471
3x
    ctx, span := observability.TraceAIFunction(ctx, "generate_question",
472
3x
        attribute.String("user.username", userConfig.Username),
473
3x
        attribute.String("ai.provider", userConfig.Provider),
474
3x
        attribute.String("ai.model", userConfig.Model),
475
3x
        observability.AttributeQuestionType(string(req.QuestionType)),
476
3x
    )
477
3x
    defer observability.FinishSpan(span, &err)
478
3x
    // Check if the provider supports grammar field
479
3x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
480
3x

481
3x
    var prompt string
482
3x
    var grammar string
483
3x

484
3x
    if supportsGrammar {
485
3x
        // Use batch prompt with count=1 for single question
486
3x
        prompt = s.buildBatchQuestionPrompt(ctx, req, nil)
487
3x
        grammar = getGrammarSchema(req.QuestionType)
488
3x
    } else {
489
        // Use batch prompt with JSON structure guidance embedded
490
        prompt = s.buildBatchQuestionPromptWithJSONStructure(ctx, req, nil)
491
        grammar = "" // No grammar field for providers that don't support it
492
    }
493

494
3x
    response, err := s.callOpenAI(ctx, userConfig, prompt, grammar)
495
3x
    if err != nil {
496
3x
        return nil, err
497
3x
    }
498

499
    question, err := s.parseQuestionResponse(ctx, response, req.Language, req.Level, req.QuestionType, userConfig.Provider)
500
    if err != nil {
501
        return nil, err
502
    }
503

504
    return question, nil
505
}
506

507
// GenerateQuestions generates multiple questions in a single batch request
508
1x
func (s *AIService) GenerateQuestions(ctx context.Context, userConfig *UserAIConfig, req *models.AIQuestionGenRequest) (result0 []*models.Question, err error) {
509
1x
    ctx, span := observability.TraceAIFunction(ctx, "generate_questions",
510
1x
        attribute.String("user.username", userConfig.Username),
511
1x
        attribute.String("ai.provider", userConfig.Provider),
512
1x
        attribute.String("ai.model", userConfig.Model),
513
1x
        observability.AttributeQuestionType(string(req.QuestionType)),
514
1x
        observability.AttributeLimit(req.Count),
515
1x
    )
516
1x
    defer observability.FinishSpan(span, &err)
517
1x
    // Check if the provider supports grammar field
518
1x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
519
1x

520
1x
    var prompt string
521
1x
    var grammar string
522
1x

523
1x
    if supportsGrammar {
524
1x
        // Use regular prompt with grammar field
525
1x
        prompt = s.buildBatchQuestionPrompt(ctx, req, nil)
526
1x
        grammar = getGrammarSchema(req.QuestionType)
527
1x
    } else {
528
        // Use prompt with JSON structure guidance embedded
529
        prompt = s.buildBatchQuestionPromptWithJSONStructure(ctx, req, nil)
530
        grammar = "" // No grammar field for providers that don't support it
531
    }
532

533
1x
    response, err := s.callOpenAI(ctx, userConfig, prompt, grammar)
534
1x
    if err != nil {
535
1x
        return nil, err
536
1x
    }
537

538
    questions, err := s.parseQuestionsResponse(ctx, response, req.Language, req.Level, req.QuestionType, userConfig.Provider)
539
    if err != nil {
540
        return nil, err
541
    }
542

543
    return questions, nil
544
}
545

546
// GenerateQuestionsStream generates questions and streams them via a channel, using the provided variety elements
547
2x
func (s *AIService) GenerateQuestionsStream(ctx context.Context, userConfig *UserAIConfig, req *models.AIQuestionGenRequest, progress chan<- *models.Question, variety *VarietyElements) (err error) {
548
2x
    ctx, span := observability.TraceAIFunction(ctx, "generate_questions_stream",
549
2x
        attribute.String("user.username", userConfig.Username),
550
2x
        attribute.String("ai.provider", userConfig.Provider),
551
2x
        attribute.String("ai.model", userConfig.Model),
552
2x
        observability.AttributeQuestionType(string(req.QuestionType)),
553
2x
        observability.AttributeLimit(req.Count),
554
2x
    )
555
2x
    defer observability.FinishSpan(span, &err)
556
2x
    defer close(progress)
557
2x

558
2x
    return s.withConcurrencyControl(ctx, userConfig.Username, func() error {
559
1x
        // Get the batch size for this provider
560
1x
        batchSize := s.getQuestionBatchSize(userConfig.Provider)
561
1x
        // Use batch generation for multiple questions
562
1x
        return s.generateQuestionsInBatchesWithVariety(ctx, userConfig, req, progress, batchSize, variety)
563
1x
    })
564
}
565

566
// generateQuestionsInBatchesWithVariety generates questions in batches for efficiency, using the provided variety elements
567
1x
func (s *AIService) generateQuestionsInBatchesWithVariety(ctx context.Context, userConfig *UserAIConfig, req *models.AIQuestionGenRequest, progress chan<- *models.Question, batchSize int, variety *VarietyElements) (err error) {
568
1x
    ctx, span := observability.TraceAIFunction(ctx, "generate_questions_in_batches_with_variety",
569
1x
        attribute.String("ai.provider", userConfig.Provider),
570
1x
        attribute.String("ai.model", userConfig.Model),
571
1x
        observability.AttributeQuestionType(req.QuestionType),
572
1x
        observability.AttributeLanguage(req.Language),
573
1x
        observability.AttributeLevel(req.Level),
574
1x
        attribute.Int("batch_size", batchSize),
575
1x
        attribute.Int("total_count", req.Count),
576
1x
        attribute.Bool("variety.enabled", variety != nil),
577
1x
    )
578
1x
    defer observability.FinishSpan(span, &err)
579
1x
    // Local copy of history to be updated as we generate questions
580
1x
    localHistory := make([]string, len(req.RecentQuestionHistory))
581
1x
    copy(localHistory, req.RecentQuestionHistory)
582
1x

583
1x
    remaining := req.Count
584
1x
    generated := 0
585
1x

586
1x
    for remaining > 0 {
587
1x
        // Check for context cancellation
588
1x
        select {
589
        case <-ctx.Done():
590
            return ctx.Err()
591
1x
        default:
592
        }
593

594
        // Calculate how many questions to generate in this batch
595
1x
        currentBatchSize := min(remaining, batchSize)
596
1x

597
1x
        // Create a batch request
598
1x
        batchReq := &models.AIQuestionGenRequest{
599
1x
            Language:              req.Language,
600
1x
            Level:                 req.Level,
601
1x
            QuestionType:          req.QuestionType,
602
1x
            Count:                 currentBatchSize,
603
1x
            RecentQuestionHistory: localHistory,
604
1x
        }
605
1x

606
1x
        // Generate questions in batch using the provided variety elements
607
1x
        questions, err := s.generateQuestionsWithVariety(ctx, userConfig, batchReq, variety)
608
1x
        if err != nil {
609
1x
            return contextutils.WrapErrorf(err, "failed to generate batch of %d questions for user %s", currentBatchSize, userConfig.Username)
610
1x
        }
611

612
        // Stream the generated questions
613
        for _, question := range questions {
614
            // Add generated question content to history for next iterations
615
            if qContent, ok := question.Content["question"]; ok {
616
                if qStr, ok := qContent.(string); ok {
617
                    localHistory = append(localHistory, qStr)
618
                }
619
            }
620

621
            progress <- question
622
            generated++
623
        }
624

625
        remaining -= len(questions)
626
    }
627

628
    return nil
629
}
630

631
// generateQuestionsWithVariety generates a batch of questions using the provided variety elements
632
1x
func (s *AIService) generateQuestionsWithVariety(ctx context.Context, userConfig *UserAIConfig, req *models.AIQuestionGenRequest, variety *VarietyElements) (result0 []*models.Question, err error) {
633
1x
    ctx, span := observability.TraceAIFunction(ctx, "generate_questions_with_variety",
634
1x
        attribute.String("ai.provider", userConfig.Provider),
635
1x
        attribute.String("ai.model", userConfig.Model),
636
1x
        observability.AttributeQuestionType(req.QuestionType),
637
1x
        observability.AttributeLanguage(req.Language),
638
1x
        observability.AttributeLevel(req.Level),
639
1x
        attribute.Int("count", req.Count),
640
1x
        attribute.Bool("variety.enabled", variety != nil),
641
1x
    )
642
1x
    defer func() {
643
1x
        if err != nil {
644
1x
            span.RecordError(err, trace.WithStackTrace(true))
645
1x
            span.SetStatus(codes.Error, err.Error())
646
1x
        }
647
1x
        span.End()
648
    }()
649
    // Check if the provider supports grammar field
650
1x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
651
1x

652
1x
    var prompt string
653
1x
    var grammar string
654
1x

655
1x
    if supportsGrammar {
656
        prompt = s.buildBatchQuestionPrompt(ctx, req, variety)
657
        grammar = getGrammarSchema(req.QuestionType)
658
    } else {
659
1x
        prompt = s.buildBatchQuestionPromptWithJSONStructure(ctx, req, variety)
660
1x
        grammar = ""
661
1x
    }
662

663
1x
    response, err := s.callOpenAI(ctx, userConfig, prompt, grammar)
664
1x
    if err != nil {
665
1x
        return nil, err
666
1x
    }
667

668
    questions, err := s.parseQuestionsResponse(ctx, response, req.Language, req.Level, req.QuestionType, userConfig.Provider)
669
    if err != nil {
670
        return nil, err
671
    }
672

673
    return questions, nil
674
}
675

676
// GenerateChatResponse generates a chat response using AI
677
1x
func (s *AIService) GenerateChatResponse(ctx context.Context, userConfig *UserAIConfig, req *models.AIChatRequest) (result0 string, err error) {
678
1x
    ctx, span := observability.TraceAIFunction(ctx, "generate_chat_response",
679
1x
        attribute.String("user.username", userConfig.Username),
680
1x
        attribute.String("ai.provider", userConfig.Provider),
681
1x
        attribute.String("ai.model", userConfig.Model),
682
1x
    )
683
1x
    defer observability.FinishSpan(span, &err)
684
1x
    var result string
685
1x
    var resultErr error
686
1x

687
1x
    err = s.withConcurrencyControl(ctx, userConfig.Username, func() error {
688
1x
        prompt := s.buildChatPrompt(req)
689
1x
        // No grammar constraint for open-ended chat
690
1x
        result, resultErr = s.callOpenAI(ctx, userConfig, prompt, "")
691
1x
        return resultErr
692
1x
    })
693
1x
    if err != nil {
694
1x
        return "", err
695
1x
    }
696
    return result, resultErr
697
}
698

699
// GenerateChatResponseStream generates a streaming chat response using AI
700
2x
func (s *AIService) GenerateChatResponseStream(ctx context.Context, userConfig *UserAIConfig, req *models.AIChatRequest, chunks chan<- string) (err error) {
701
2x
    ctx, span := observability.TraceAIFunction(ctx, "generate_chat_response_stream",
702
2x
        attribute.String("user.username", userConfig.Username),
703
2x
        attribute.String("ai.provider", userConfig.Provider),
704
2x
        attribute.String("ai.model", userConfig.Model),
705
2x
    )
706
2x
    defer observability.FinishSpan(span, &err)
707
2x
    // Don't close the channel here - let the caller handle it to avoid race conditions
708
2x

709
2x
    return s.withConcurrencyControl(ctx, userConfig.Username, func() error {
710
1x
        prompt := s.buildChatPrompt(req)
711
1x
        // No grammar constraint for open-ended chat
712
1x
        return s.callOpenAIStream(ctx, userConfig, prompt, "", chunks)
713
1x
    })
714
}
715

716
// TestConnection tests the connection to the AI service
717
3x
func (s *AIService) TestConnection(ctx context.Context, provider, model, apiKey string) (err error) {
718
3x
    _, span := observability.TraceAIFunction(ctx, "test_connection",
719
3x
        attribute.String("ai.provider", provider),
720
3x
        attribute.String("ai.model", model),
721
3x
    )
722
3x
    defer observability.FinishSpan(span, &err)
723
3x

724
3x
    // Validate input parameters
725
3x
    if provider == "" {
726
        span.SetAttributes(attribute.String("test.result", "empty_provider"))
727
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "provider is required for testing connection")
728
    }
729

730
3x
    if model == "" {
731
        span.SetAttributes(attribute.String("test.result", "empty_model"))
732
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "model is required for testing connection")
733
    }
734

735
3x
    s.logger.Debug(ctx, "TestConnection called", map[string]interface{}{
736
3x
        "provider": provider,
737
3x
        "model":    model,
738
3x
        "apiKey":   contextutils.MaskAPIKey(apiKey),
739
3x
    })
740
3x

741
3x
    // Require API key for all providers that are not Ollama
742
3x
    if provider != "ollama" && apiKey == "" {
743
1x
        span.SetAttributes(attribute.String("test.result", "missing_api_key"), attribute.String("provider", provider))
744
1x
        return contextutils.WrapErrorf(contextutils.ErrAIConfigInvalid, "API key is required for testing connection with provider '%s'", provider)
745
1x
    }
746

747
    // Create a simple test configuration
748
2x
    userConfig := &UserAIConfig{
749
2x
        Provider: provider,
750
2x
        Model:    model,
751
2x
        APIKey:   apiKey,
752
2x
        Username: "test-user",
753
2x
    }
754
2x

755
2x
    s.logger.Debug(ctx, "Created userConfig", map[string]interface{}{
756
2x
        "provider": userConfig.Provider,
757
2x
        "model":    userConfig.Model,
758
2x
    })
759
2x

760
2x
    // Create a simple test request
761
2x
    testPrompt := "Respond with exactly the word 'SUCCESS' and nothing else."
762
2x

763
2x
    // Create a timeout context for the test
764
2x
    testCtx, cancel := context.WithTimeout(ctx, config.AIRequestTimeout)
765
2x
    defer cancel()
766
2x

767
2x
    // Test the actual AI service call
768
2x
    response, err := s.callOpenAI(testCtx, userConfig, testPrompt, "")
769
2x
    if err != nil {
770
2x
        span.SetAttributes(attribute.String("test.result", "call_failed"), attribute.String("error", err.Error()))
771
2x
        return contextutils.WrapErrorf(err, "connection test failed for provider '%s' with model '%s'", provider, model)
772
2x
    }
773

774
    // Check if we got a reasonable response
775
    if response == "" {
776
        span.SetAttributes(attribute.String("test.result", "empty_response"))
777
        return contextutils.WrapError(contextutils.ErrAIResponseInvalid, "connection test failed: received empty response from AI service")
778
    }
779

780
    // Validate that the response contains something meaningful
781
    if len(response) < 3 {
782
        span.SetAttributes(attribute.String("test.result", "response_too_short"), attribute.Int("response_length", len(response)))
783
        return contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "connection test failed: response too short (%d characters)", len(response))
784
    }
785

786
    // The response should contain something meaningful
787
    s.logger.Info(ctx, "TestConnection successful", map[string]interface{}{
788
        "provider":        provider,
789
        "response_length": len(response),
790
    })
791
    span.SetAttributes(attribute.String("test.result", "success"), attribute.Int("response_length", len(response)))
792
    return nil
793
}
794

795
// buildBatchQuestionPromptWithJSONStructure now takes variety elements
796
3x
func (s *AIService) buildBatchQuestionPromptWithJSONStructure(ctx context.Context, req *models.AIQuestionGenRequest, variety *VarietyElements) string {
797
3x
    prompt := s.buildBatchQuestionPrompt(ctx, req, variety)
798
3x
    return s.addJSONStructureGuidance(prompt, req.QuestionType)
799
3x
}
800

801
// buildBatchQuestionPrompt now takes variety elements
802
11x
func (s *AIService) buildBatchQuestionPrompt(ctx context.Context, req *models.AIQuestionGenRequest, variety *VarietyElements) string {
803
11x
    _, span := observability.TraceAIFunction(ctx, "build_batch_question_prompt",
804
11x
        observability.AttributeQuestionType(req.QuestionType),
805
11x
        observability.AttributeLanguage(req.Language),
806
11x
        observability.AttributeLevel(req.Level),
807
11x
        attribute.Int("count", req.Count),
808
11x
        attribute.Bool("variety.enabled", variety != nil),
809
11x
    )
810
11x
    defer span.End()
811
11x
    tmplData := AITemplateData{
812
11x
        SchemaForPrompt:       getGrammarSchema(req.QuestionType),
813
11x
        Language:              req.Language,
814
11x
        Level:                 req.Level,
815
11x
        QuestionType:          string(req.QuestionType),
816
11x
        Count:                 req.Count,
817
11x
        RecentQuestionHistory: req.RecentQuestionHistory,
818
11x
    }
819
11x
    if variety != nil {
820
4x
        tmplData.TopicCategory = variety.TopicCategory
821
4x
        tmplData.GrammarFocus = variety.GrammarFocus
822
4x
        tmplData.VocabularyDomain = variety.VocabularyDomain
823
4x
        tmplData.Scenario = variety.Scenario
824
4x
        tmplData.StyleModifier = variety.StyleModifier
825
4x
        tmplData.DifficultyModifier = variety.DifficultyModifier
826
4x
        tmplData.TimeContext = variety.TimeContext
827
4x
    }
828

829
    // Priority data is handled by the worker, not passed to AI service
830

831
    // Load example for this question type
832
11x
    if exampleContent, err := s.templateManager.LoadExample(string(req.QuestionType)); err == nil {
833
11x
        tmplData.ExampleContent = exampleContent
834
11x
    }
835

836
11x
    prompt, err := s.templateManager.RenderTemplate(BatchQuestionPromptTemplate, tmplData)
837
11x
    if err != nil {
838
        s.logger.Error(ctx, "Failed to render batch question prompt template", err, map[string]interface{}{})
839
        panic(err) // Use panic for fatal errors in template rendering
840
    }
841

842
11x
    return prompt
843
}
844

845
13x
func (s *AIService) buildChatPrompt(req *models.AIChatRequest) string {
846
13x
    // Convert conversation history to template format
847
13x
    var conversationHistory []ChatMessage
848
13x
    for _, msg := range req.ConversationHistory {
849
        conversationHistory = append(conversationHistory, ChatMessage{
850
            Role:    string(msg.Role),
851
            Content: msg.Content,
852
        })
853
    }
854

855
13x
    data := AITemplateData{
856
13x
        Language:            req.Language,
857
13x
        Level:               req.Level,
858
13x
        QuestionType:        string(req.QuestionType),
859
13x
        Passage:             req.Passage,
860
13x
        Question:            req.Question,
861
13x
        Options:             req.Options,
862
13x
        IsCorrect:           req.IsCorrect,
863
13x
        ConversationHistory: conversationHistory,
864
13x
        UserMessage:         req.UserMessage,
865
13x
    }
866
13x

867
13x
    prompt, err := s.templateManager.RenderTemplate(ChatPromptTemplate, data)
868
13x
    if err != nil {
869
        s.logger.Error(context.Background(), "Failed to render chat prompt template", err, map[string]interface{}{})
870
        panic(err) // Use panic for fatal errors in template rendering
871
    }
872

873
13x
    return prompt
874
}
875

876
// getMaxTokensForModel looks up the max_tokens for a specific provider and model
877
9x
func (s *AIService) getMaxTokensForModel(provider, model string) int {
878
9x
    // Look up the model in the provider configuration
879
9x
    if s.cfg.Providers != nil {
880
9x
        for _, providerConfig := range s.cfg.Providers {
881
10x
            if providerConfig.Code == provider {
882
5x
                for _, modelConfig := range providerConfig.Models {
883
                    if modelConfig.Code == model {
884
                        if modelConfig.MaxTokens > 0 {
885
                            return modelConfig.MaxTokens
886
                        }
887
                        break
888
                    }
889
                }
890
5x
                break
891
            }
892
        }
893
    }
894

895
    // Default fallback
896
9x
    return 4000
897
}
898

899
// callOpenAI makes a request to the OpenAI-compatible API
900
8x
func (s *AIService) callOpenAI(ctx context.Context, userConfig *UserAIConfig, prompt, grammar string) (result0 string, err error) {
901
8x
    if userConfig == nil {
902
        return "", contextutils.WrapError(contextutils.ErrAIConfigInvalid, "userConfig is required")
903
    }
904
8x
    _, span := observability.TraceAIFunction(ctx, "call_openai",
905
8x
        attribute.String("ai.provider", userConfig.Provider),
906
8x
        attribute.String("ai.model", userConfig.Model),
907
8x
        attribute.String("ai.username", userConfig.Username),
908
8x
        attribute.Int("prompt.length", len(prompt)),
909
8x
        attribute.Bool("grammar.enabled", grammar != ""),
910
8x
    )
911
8x
    defer func() {
912
8x
        if err != nil {
913
8x
            span.RecordError(err, trace.WithStackTrace(true))
914
8x
            span.SetStatus(codes.Error, err.Error())
915
8x
        }
916
8x
        span.End()
917
    }()
918

919
    // Validate input parameters
920
8x
    if userConfig.Provider == "" {
921
        span.SetAttributes(attribute.String("call.result", "empty_provider"))
922
        return "", contextutils.WrapError(contextutils.ErrAIConfigInvalid, "provider is required")
923
    }
924

925
8x
    if userConfig.Model == "" {
926
        span.SetAttributes(attribute.String("call.result", "empty_model"))
927
        return "", contextutils.WrapError(contextutils.ErrAIConfigInvalid, "model is required")
928
    }
929

930
8x
    if prompt == "" {
931
        span.SetAttributes(attribute.String("call.result", "empty_prompt"))
932
        return "", contextutils.WrapError(contextutils.ErrAIConfigInvalid, "prompt cannot be empty")
933
    }
934

935
8x
    apiURL := ""
936
8x
    model := userConfig.Model
937
8x
    apiKey := userConfig.APIKey
938
8x

939
8x
    // Look up the default URL from provider config
940
8x
    if s.cfg.Providers != nil {
941
8x
        for _, providerConfig := range s.cfg.Providers {
942
8x
            if providerConfig.Code == userConfig.Provider && providerConfig.URL != "" {
943
4x
                apiURL = providerConfig.URL
944
4x
                break
945
            }
946
        }
947
    }
948

949
8x
    if apiURL == "" {
950
4x
        span.SetAttributes(attribute.String("call.result", "no_url_configured"), attribute.String("provider", userConfig.Provider))
951
4x
        return "", contextutils.WrapErrorf(contextutils.ErrAIConfigInvalid, "no base URL configured for provider '%s'", userConfig.Provider)
952
4x
    }
953

954
4x
    userPrefix := ""
955
4x
    if userConfig.Username != "" {
956
        userPrefix = fmt.Sprintf("[user=%s] ", userConfig.Username)
957
    }
958

959
4x
    s.logger.Debug(ctx, "Starting AI request", map[string]interface{}{
960
4x
        "user_prefix": userPrefix,
961
4x
        "url":         apiURL + "/chat/completions",
962
4x
        "model":       model,
963
4x
        "provider":    userConfig.Provider,
964
4x
    })
965
4x

966
4x
    // Create messages with just the user prompt - grammar field will enforce JSON structure
967
4x
    messages := []Message{{Role: "user", Content: prompt}}
968
4x

969
4x
    // Check if the provider supports grammar field
970
4x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
971
4x

972
4x
    reqBody := OpenAIRequest{
973
4x
        Model:       model,
974
4x
        Messages:    messages,
975
4x
        Temperature: 0.7,
976
4x
        MaxTokens:   s.getMaxTokensForModel(userConfig.Provider, userConfig.Model),
977
4x
    }
978
4x

979
4x
    // Only include grammar field if the provider supports it
980
4x
    if supportsGrammar && grammar != "" {
981
4x
        reqBody.Grammar = grammar
982
4x
    }
983

984
4x
    jsonData, err := json.Marshal(reqBody)
985
4x
    if err != nil {
986
        s.logger.Error(ctx, "Failed to marshal AI request", err, map[string]interface{}{
987
            "user_prefix": userPrefix,
988
        })
989
        span.SetAttributes(attribute.String("call.result", "marshal_failed"), attribute.String("error", err.Error()))
990
        return "", contextutils.WrapErrorf(err, "failed to marshal request body")
991
    }
992

993
4x
    s.logger.Debug(ctx, "Making AI HTTP request", map[string]interface{}{
994
4x
        "user_prefix": userPrefix,
995
4x
        "url":         apiURL + "/chat/completions",
996
4x
    })
997
4x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL+"/chat/completions", bytes.NewBuffer(jsonData))
998
4x
    if err != nil {
999
        s.logger.Error(ctx, "Failed to create AI HTTP request", err, map[string]interface{}{
1000
            "user_prefix": userPrefix,
1001
        })
1002
        span.SetAttributes(attribute.String("call.result", "request_creation_failed"), attribute.String("error", err.Error()))
1003
        return "", contextutils.WrapErrorf(err, "failed to create HTTP request")
1004
    }
1005

1006
4x
    req.Header.Set("Content-Type", "application/json")
1007
4x
    if apiKey != "" {
1008
        req.Header.Set("Authorization", "Bearer "+apiKey)
1009
        s.logger.Debug(ctx, "Using API key authentication", map[string]interface{}{
1010
            "user_prefix": userPrefix,
1011
        })
1012
    } else {
1013
4x
        s.logger.Debug(ctx, "No API key provided, using anonymous access", map[string]interface{}{
1014
4x
            "user_prefix": userPrefix,
1015
4x
        })
1016
4x
    }
1017

1018
4x
    startTime := time.Now()
1019
4x
    resp, err := s.httpClient.Do(req.WithContext(ctx))
1020
4x
    duration := time.Since(startTime)
1021
4x

1022
4x
    if err != nil {
1023
1x
        s.logger.Error(ctx, "AI HTTP request failed", err, map[string]interface{}{
1024
1x
            "user_prefix": userPrefix,
1025
1x
            "duration":    duration.String(),
1026
1x
        })
1027
1x
        span.SetAttributes(attribute.String("call.result", "http_request_failed"), attribute.String("error", err.Error()), attribute.String("duration", duration.String()))
1028
1x
        return "", contextutils.WrapErrorf(err, "HTTP request failed after %v", duration)
1029
1x
    }
1030
3x
    defer func() {
1031
3x
        if err := resp.Body.Close(); err != nil {
1032
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{
1033
                "error": err.Error(),
1034
            })
1035
        }
1036
    }()
1037

1038
3x
    s.logger.Info(ctx, "AI Service HTTP request completed", map[string]interface{}{
1039
3x
        "user_prefix": userPrefix,
1040
3x
        "duration":    duration.String(),
1041
3x
        "status_code": resp.StatusCode,
1042
3x
    })
1043
3x

1044
3x
    body, err := io.ReadAll(resp.Body)
1045
3x
    if err != nil {
1046
        span.SetAttributes(attribute.String("call.result", "body_read_failed"), attribute.String("error", err.Error()))
1047
        return "", contextutils.WrapErrorf(err, "failed to read response body")
1048
    }
1049

1050
3x
    if resp.StatusCode != http.StatusOK {
1051
1x
        span.SetAttributes(attribute.String("call.result", "http_error"), attribute.Int("status_code", resp.StatusCode), attribute.String("body", string(body)))
1052
1x
        return "", contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "API request failed with status %d to %s: %s", resp.StatusCode, apiURL+"/chat/completions", string(body))
1053
1x
    }
1054

1055
2x
    var openAIResp OpenAIResponse
1056
2x
    if err := json.Unmarshal(body, &openAIResp); err != nil {
1057
1x
        span.SetAttributes(attribute.String("call.result", "json_unmarshal_failed"), attribute.String("error", err.Error()), attribute.String("body", string(body)))
1058
1x
        return "", contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "failed to parse AI response as JSON: %w. Raw Response: %s", err, string(body))
1059
1x
    }
1060

1061
1x
    if openAIResp.Error != nil {
1062
        span.SetAttributes(attribute.String("call.result", "api_error"), attribute.String("error_message", openAIResp.Error.Message), attribute.String("error_type", openAIResp.Error.Type))
1063
        return "", contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "OpenAI API error: %s", openAIResp.Error.Message)
1064
    }
1065

1066
1x
    if len(openAIResp.Choices) == 0 {
1067
1x
        span.SetAttributes(attribute.String("call.result", "no_choices"))
1068
1x
        return "", contextutils.WrapError(contextutils.ErrAIResponseInvalid, "no response from OpenAI")
1069
1x
    }
1070

1071
    content := openAIResp.Choices[0].Message.Content
1072
    if content == "" {
1073
        span.SetAttributes(attribute.String("call.result", "empty_content"))
1074
        return "", contextutils.WrapError(contextutils.ErrAIResponseInvalid, "AI returned empty content")
1075
    }
1076

1077
    span.SetAttributes(attribute.String("call.result", "success"), attribute.Int("content_length", len(content)), attribute.String("duration", duration.String()))
1078
    return content, nil
1079
}
1080

1081
// callOpenAIStream makes a streaming request to the OpenAI-compatible API
1082
1x
func (s *AIService) callOpenAIStream(ctx context.Context, userConfig *UserAIConfig, prompt, grammar string, chunks chan<- string) error {
1083
1x
    if userConfig == nil {
1084
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "userConfig is required")
1085
    }
1086
1x
    _, span := observability.TraceAIFunction(ctx, "call_openai_stream",
1087
1x
        attribute.String("ai.provider", userConfig.Provider),
1088
1x
        attribute.String("ai.model", userConfig.Model),
1089
1x
        attribute.String("ai.username", userConfig.Username),
1090
1x
        attribute.Int("prompt.length", len(prompt)),
1091
1x
        attribute.Bool("grammar.enabled", grammar != ""),
1092
1x
    )
1093
1x
    defer span.End()
1094
1x

1095
1x
    // Validate input parameters
1096
1x
    if userConfig.Provider == "" {
1097
        span.SetAttributes(attribute.String("stream.result", "empty_provider"))
1098
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "provider is required")
1099
    }
1100

1101
1x
    if userConfig.Model == "" {
1102
        span.SetAttributes(attribute.String("stream.result", "empty_model"))
1103
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "model is required")
1104
    }
1105

1106
1x
    if prompt == "" {
1107
        span.SetAttributes(attribute.String("stream.result", "empty_prompt"))
1108
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "prompt cannot be empty")
1109
    }
1110

1111
1x
    if chunks == nil {
1112
        span.SetAttributes(attribute.String("stream.result", "nil_chunks_channel"))
1113
        return contextutils.WrapError(contextutils.ErrAIConfigInvalid, "chunks channel is required")
1114
    }
1115

1116
1x
    apiURL := ""
1117
1x
    model := userConfig.Model
1118
1x
    apiKey := userConfig.APIKey
1119
1x

1120
1x
    // Look up the default URL from provider config
1121
1x
    if s.cfg.Providers != nil {
1122
1x
        for _, providerConfig := range s.cfg.Providers {
1123
2x
            if providerConfig.Code == userConfig.Provider && providerConfig.URL != "" {
1124
1x
                apiURL = providerConfig.URL
1125
1x
                break
1126
            }
1127
        }
1128
    }
1129

1130
1x
    if apiURL == "" {
1131
        span.SetAttributes(attribute.String("stream.result", "no_url_configured"), attribute.String("provider", userConfig.Provider))
1132
        return contextutils.WrapErrorf(contextutils.ErrAIConfigInvalid, "no base URL configured for provider '%s'", userConfig.Provider)
1133
    }
1134

1135
1x
    userPrefix := ""
1136
1x
    if userConfig.Username != "" {
1137
1x
        userPrefix = fmt.Sprintf("[user=%s] ", userConfig.Username)
1138
1x
    }
1139

1140
1x
    s.logger.Info(ctx, "AI Service Starting streaming request", map[string]interface{}{
1141
1x
        "user_prefix": userPrefix,
1142
1x
        "api_url":     apiURL + "/chat/completions",
1143
1x
        "model":       model,
1144
1x
        "provider":    userConfig.Provider,
1145
1x
    })
1146
1x

1147
1x
    // Create messages with just the user prompt - grammar field will enforce JSON structure
1148
1x
    messages := []Message{{Role: "user", Content: prompt}}
1149
1x

1150
1x
    // Check if the provider supports grammar field
1151
1x
    supportsGrammar := s.supportsGrammarField(userConfig.Provider)
1152
1x

1153
1x
    reqBody := OpenAIRequest{
1154
1x
        Model:       model,
1155
1x
        Messages:    messages,
1156
1x
        Temperature: 0.7,
1157
1x
        MaxTokens:   s.getMaxTokensForModel(userConfig.Provider, userConfig.Model),
1158
1x
        Stream:      true, // Enable streaming
1159
1x
    }
1160
1x

1161
1x
    // Only include grammar field if the provider supports it
1162
1x
    if supportsGrammar && grammar != "" {
1163
        reqBody.Grammar = grammar
1164
    }
1165

1166
1x
    jsonData, err := json.Marshal(reqBody)
1167
1x
    if err != nil {
1168
        s.logger.Error(ctx, "Failed to marshal request", err, map[string]interface{}{
1169
            "user_prefix": userPrefix,
1170
        })
1171
        span.SetAttributes(attribute.String("stream.result", "marshal_failed"), attribute.String("error", err.Error()))
1172
        return contextutils.WrapErrorf(err, "failed to marshal streaming request body")
1173
    }
1174

1175
1x
    s.logger.Info(ctx, "AI Service Making streaming HTTP request", map[string]interface{}{
1176
1x
        "user_prefix": userPrefix,
1177
1x
        "api_url":     apiURL + "/chat/completions",
1178
1x
    })
1179
1x
    req, err := http.NewRequestWithContext(ctx, "POST", apiURL+"/chat/completions", bytes.NewBuffer(jsonData))
1180
1x
    if err != nil {
1181
        s.logger.Error(ctx, "Failed to create HTTP request", err, map[string]interface{}{
1182
            "user_prefix": userPrefix,
1183
        })
1184
        span.SetAttributes(attribute.String("stream.result", "request_creation_failed"), attribute.String("error", err.Error()))
1185
        return contextutils.WrapErrorf(err, "failed to create streaming HTTP request")
1186
    }
1187

1188
1x
    req.Header.Set("Content-Type", "application/json")
1189
1x
    req.Header.Set("Accept", "text/event-stream")
1190
1x
    req.Header.Set("Cache-Control", "no-cache")
1191
1x
    if apiKey != "" {
1192
1x
        req.Header.Set("Authorization", "Bearer "+apiKey)
1193
1x
        s.logger.Info(ctx, "AI Service Using API key authentication", map[string]interface{}{
1194
1x
            "user_prefix": userPrefix,
1195
1x
        })
1196
1x
    } else {
1197
        s.logger.Info(ctx, "AI Service No API key provided, using anonymous access", map[string]interface{}{
1198
            "user_prefix": userPrefix,
1199
        })
1200
    }
1201

1202
1x
    startTime := time.Now()
1203
1x
    resp, err := s.httpClient.Do(req.WithContext(ctx))
1204
1x
    if err != nil {
1205
1x
        s.logger.Error(ctx, "HTTP request failed", err, map[string]interface{}{
1206
1x
            "user_prefix": userPrefix,
1207
1x
        })
1208
1x
        span.SetAttributes(attribute.String("stream.result", "http_request_failed"), attribute.String("error", err.Error()))
1209
1x
        return contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "http client error: %w", err)
1210
1x
    }
1211
    defer func() {
1212
        if err := resp.Body.Close(); err != nil {
1213
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{
1214
                "error": err.Error(),
1215
            })
1216
        }
1217
    }()
1218

1219
    if resp.StatusCode != http.StatusOK {
1220
        body, _ := io.ReadAll(resp.Body)
1221
        span.SetAttributes(attribute.String("stream.result", "http_error"), attribute.Int("status_code", resp.StatusCode), attribute.String("body", string(body)))
1222
        return contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "API request failed with status %d to %s: %s", resp.StatusCode, apiURL+"/chat/completions", string(body))
1223
    }
1224

1225
    s.logger.Info(ctx, "AI Service Streaming response started", map[string]interface{}{
1226
        "user_prefix": userPrefix,
1227
        "duration":    time.Since(startTime).String(),
1228
    })
1229

1230
    // Read the streaming response
1231
    scanner := bufio.NewScanner(resp.Body)
1232
    var chunkCount int
1233
    var totalContentLength int
1234

1235
    for scanner.Scan() {
1236
        line := scanner.Text()
1237

1238
        // Skip empty lines and comments
1239
        if line == "" || strings.HasPrefix(line, ":") {
1240
            continue
1241
        }
1242

1243
        // Parse Server-Sent Events format
1244
        if strings.HasPrefix(line, "data: ") {
1245
            data := strings.TrimPrefix(line, "data: ")
1246

1247
            // Check for end of stream
1248
            if data == "[DONE]" {
1249
                break
1250
            }
1251

1252
            // Parse the JSON chunk
1253
            var streamResp OpenAIStreamResponse
1254
            if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
1255
                s.logger.Warn(ctx, "AI Service WARNING: Failed to parse streaming chunk", map[string]interface{}{
1256
                    "error": err.Error(),
1257
                    "data":  data,
1258
                })
1259
                span.SetAttributes(attribute.String("stream.result", "chunk_parse_failed"), attribute.String("error", err.Error()), attribute.String("data", data))
1260
                continue
1261
            }
1262

1263
            if streamResp.Error != nil {
1264
                span.SetAttributes(attribute.String("stream.result", "api_streaming_error"), attribute.String("error_message", streamResp.Error.Message), attribute.String("error_type", streamResp.Error.Type))
1265
                return contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "OpenAI API streaming error: %s", streamResp.Error.Message)
1266
            }
1267

1268
            // Extract content from the chunk
1269
            if len(streamResp.Choices) > 0 && streamResp.Choices[0].Delta.Content != "" {
1270
                content := streamResp.Choices[0].Delta.Content
1271
                totalContentLength += len(content)
1272

1273
                // Filter out thinking content for thinking models
1274
                filteredContent := s.filterThinkingContent(content, model)
1275

1276
                if filteredContent != "" {
1277
                    select {
1278
                    case chunks <- filteredContent:
1279
                        chunkCount++
1280
                    case <-ctx.Done():
1281
                        span.SetAttributes(attribute.String("stream.result", "context_cancelled"))
1282
                        return ctx.Err()
1283
                    }
1284
                }
1285
            }
1286

1287
            // Check if streaming is finished
1288
            if len(streamResp.Choices) > 0 && streamResp.Choices[0].FinishReason != nil {
1289
                break
1290
            }
1291
        }
1292
    }
1293

1294
    if err := scanner.Err(); err != nil {
1295
        span.SetAttributes(attribute.String("stream.result", "scanner_error"), attribute.String("error", err.Error()))
1296
        return contextutils.WrapErrorf(contextutils.ErrAIRequestFailed, "error reading streaming response: %w", err)
1297
    }
1298

1299
    s.logger.Info(ctx, "AI Service Streaming response completed", map[string]interface{}{
1300
        "user_prefix":          userPrefix,
1301
        "duration":             time.Since(startTime).String(),
1302
        "chunk_count":          chunkCount,
1303
        "total_content_length": totalContentLength,
1304
    })
1305
    span.SetAttributes(attribute.String("stream.result", "success"), attribute.Int("chunk_count", chunkCount), attribute.Int("total_content_length", totalContentLength), attribute.String("duration", time.Since(startTime).String()))
1306
    return nil
1307
}
1308

1309
// filterThinkingContent filters out thinking sections for reasoning models
1310
3x
func (s *AIService) filterThinkingContent(content, model string) string {
1311
3x
    // Check if this is a thinking/reasoning model
1312
3x
    if !s.isThinkingModel(model) {
1313
1x
        return content
1314
1x
    }
1315

1316
    // For thinking models, filter out content between <thinking> tags
1317
2x
    if strings.Contains(content, "<thinking>") || strings.Contains(content, "</thinking>") {
1318
        return ""
1319
    }
1320

1321
2x
    if idx := strings.Index(content, "The answer is:"); idx != -1 {
1322
1x
        answer := content[idx+len("The answer is:"):]
1323
1x
        lines := strings.Split(answer, "\n")
1324
1x
        for _, line := range lines {
1325
1x
            trimmed := strings.TrimSpace(line)
1326
1x
            if trimmed != "" {
1327
1x
                return trimmed
1328
1x
            }
1329
        }
1330
        return ""
1331
    }
1332

1333
1x
    trimmed := strings.TrimSpace(content)
1334
1x
    if strings.HasPrefix(trimmed, "I need to") ||
1335
1x
        strings.HasPrefix(trimmed, "Let me think") ||
1336
1x
        strings.HasPrefix(trimmed, "First, I'll") {
1337
        return ""
1338
    }
1339

1340
1x
    return content
1341
}
1342

1343
// isThinkingModel checks if the model is a reasoning/thinking model
1344
9x
func (s *AIService) isThinkingModel(model string) bool {
1345
9x
    thinkingModels := []string{
1346
9x
        "o1-preview",
1347
9x
        "o1-mini",
1348
9x
        "o1",
1349
9x
        "qwen2.5-coder:32b",
1350
9x
        "deepseek-r1",
1351
9x
        "marco-o1",
1352
9x
        "gpt-4",
1353
9x
        "gpt-4-turbo",
1354
9x
        "claude-3",
1355
9x
    }
1356
9x

1357
9x
    modelLower := strings.ToLower(model)
1358
9x
    for _, thinkingModel := range thinkingModels {
1359
73x
        if strings.Contains(modelLower, strings.ToLower(thinkingModel)) {
1360
5x
            return true
1361
5x
        }
1362
    }
1363

1364
4x
    return false
1365
}
1366

1367
// cleanJSONResponse extracts JSON from markdown code blocks or returns the original response
1368
43x
func (s *AIService) cleanJSONResponse(ctx context.Context, response, provider string) string {
1369
43x
    _, span := observability.TraceAIFunction(ctx, "clean_json_response",
1370
43x
        attribute.String("ai.provider", provider),
1371
43x
        attribute.Int("response.length", len(response)),
1372
43x
    )
1373
43x
    defer span.End()
1374
43x
    // If the provider supports grammar field, we expect clean JSON
1375
43x
    if s.supportsGrammarField(provider) {
1376
1x
        return response
1377
1x
    }
1378

1379
    // For providers that don't support grammar field, clean up markdown code blocks
1380
41x
    response = strings.TrimSpace(response)
1381
41x

1382
41x
    // Remove markdown code block markers
1383
41x
    if strings.HasPrefix(response, "```json") {
1384
2x
        response = strings.TrimPrefix(response, "```json")
1385
2x
        response = strings.TrimSuffix(response, "```")
1386
2x
    } else if strings.HasPrefix(response, "```") {
1387
        response = strings.TrimPrefix(response, "```")
1388
1x
        response = strings.TrimSuffix(response, "```")
1389
1x
    }
1390

1391
41x
    return strings.TrimSpace(response)
1392
}
1393

1394
17x
func (s *AIService) parseQuestionsResponse(ctx context.Context, response, language, level string, qType models.QuestionType, provider string) (result0 []*models.Question, err error) {
1395
17x
    if s == nil {
1396
1x
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "AIService instance is nil")
1397
1x
    }
1398
16x
    _, span := observability.TraceAIFunction(ctx, "parse_questions_response",
1399
16x
        observability.AttributeQuestionType(qType),
1400
16x
        observability.AttributeLanguage(language),
1401
16x
        observability.AttributeLevel(level),
1402
16x
        attribute.String("ai.provider", provider),
1403
16x
        attribute.Int("response.length", len(response)),
1404
16x
    )
1405
16x
    defer observability.FinishSpan(span, &err)
1406
16x
    defer func() {
1407
16x
        if r := recover(); r != nil {
1408
            s.logger.Error(ctx, "PANIC in parseQuestionsResponse", nil, map[string]interface{}{
1409
                "panic":    fmt.Sprintf("%v", r),
1410
                "response": response,
1411
                "stack":    string(debug.Stack()),
1412
            })
1413
            span.SetAttributes(attribute.String("parse.result", "panic"), attribute.String("panic", fmt.Sprintf("%v", r)))
1414
        }
1415
    }()
1416

1417
    // Validate input parameters
1418
16x
    if response == "" {
1419
1x
        span.SetAttributes(attribute.String("parse.result", "empty_response"))
1420
1x
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "AI provider returned empty response")
1421
1x
    }
1422
15x
    if language == "" {
1423
        span.SetAttributes(attribute.String("parse.result", "empty_language"))
1424
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "language cannot be empty")
1425
    }
1426
15x
    if level == "" {
1427
        span.SetAttributes(attribute.String("parse.result", "empty_level"))
1428
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "level cannot be empty")
1429
    }
1430

1431
    // Clean the response to handle markdown code blocks for providers without grammar support
1432
15x
    cleanedResponse := s.cleanJSONResponse(ctx, response, provider)
1433
15x

1434
15x
    if cleanedResponse == "" {
1435
        span.SetAttributes(attribute.String("parse.result", "empty_cleaned_response"))
1436
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "AI provider returned empty response after cleaning")
1437
    }
1438

1439
    // With grammar field enforcement, we should get clean JSON directly
1440
    // No need for complex extraction - just parse the response directly
1441
15x
    var questions []map[string]interface{}
1442
15x
    if err := json.Unmarshal([]byte(cleanedResponse), &questions); err != nil {
1443
1x
        span.SetAttributes(attribute.String("parse.result", "json_unmarshal_failed"), attribute.String("error", err.Error()))
1444
1x
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "failed to parse AI response as JSON: %w", err)
1445
1x
    }
1446

1447
14x
    if len(questions) == 0 {
1448
1x
        span.SetAttributes(attribute.String("parse.result", "no_questions_in_response"))
1449
1x
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "AI provider returned no questions in response")
1450
1x
    }
1451

1452
13x
    var result []*models.Question
1453
13x
    var validationErrors []string
1454
13x
    var skippedCount int
1455
13x

1456
13x
    for i, qData := range questions {
1457
1018x
        if qData == nil {
1458
3x
            skippedCount++
1459
3x
            span.SetAttributes(attribute.String("parse.result", "nil_question_data"), attribute.Int("question_index", i))
1460
3x
            continue
1461
        }
1462

1463
1015x
        question, err := s.createQuestionFromData(ctx, qData, language, level, qType)
1464
1015x
        if err != nil {
1465
9x
            // Try to extract more info about the failure
1466
9x
            var failedField, failedValue string
1467
9x
            for k, v := range qData {
1468
55x
                if v == nil || v == "" {
1469
2x
                    failedField = k
1470
2x
                    failedValue = fmt.Sprintf("%v", v)
1471
2x
                    break
1472
                }
1473
            }
1474
9x
            validationErrors = append(validationErrors, fmt.Sprintf("question %d: %v (field: %s, value: %s)", i+1, err, failedField, failedValue))
1475
9x
            span.SetAttributes(attribute.String("parse.result", "question_creation_failed"), attribute.Int("question_index", i), attribute.String("error", err.Error()))
1476
9x
            continue
1477
        }
1478

1479
1006x
        if question == nil {
1480
            skippedCount++
1481
            span.SetAttributes(attribute.String("parse.result", "nil_question_after_creation"), attribute.Int("question_index", i))
1482
            continue
1483
        }
1484

1485
        // Coerce correct_answer to int if it's a float64 (for schema validation)
1486
1006x
        if m := question.Content; m != nil {
1487
1006x
            if v, ok := m["correct_answer"]; ok {
1488
1006x
                switch val := v.(type) {
1489
1006x
                case float64:
1490
1006x
                    m["correct_answer"] = int(val)
1491
                }
1492
            }
1493
        }
1494

1495
1006x
        valid, err := s.ValidateQuestionSchema(ctx, qType, question)
1496
1006x
        if err != nil {
1497
            validationErrors = append(validationErrors, fmt.Sprintf("question %d schema validation error: %v", i+1, err))
1498
            span.SetAttributes(attribute.String("parse.result", "schema_validation_error"), attribute.Int("question_index", i), attribute.String("error", err.Error()))
1499
        }
1500

1501
1006x
        if !valid {
1502
            SchemaValidationMu.Lock()
1503
            SchemaValidationFailures[qType]++
1504
            if err != nil {
1505
                SchemaValidationFailureDetails[qType] = append(SchemaValidationFailureDetails[qType], err.Error())
1506
            } else {
1507
                SchemaValidationFailureDetails[qType] = append(SchemaValidationFailureDetails[qType], "validation failed")
1508
            }
1509
            if len(SchemaValidationFailureDetails[qType]) > 10 {
1510
                SchemaValidationFailureDetails[qType] = SchemaValidationFailureDetails[qType][len(SchemaValidationFailureDetails[qType])-10:]
1511
            }
1512
            SchemaValidationMu.Unlock()
1513
            skippedCount++
1514
            span.SetAttributes(attribute.String("parse.result", "schema_validation_failed"), attribute.Int("question_index", i))
1515
            continue // skip invalid question
1516
        }
1517

1518
1006x
        result = append(result, question)
1519
    }
1520

1521
    // Log validation summary
1522
13x
    if len(validationErrors) > 0 {
1523
8x
        s.logger.Warn(ctx, "AI Service WARNING: validation errors in response", map[string]interface{}{
1524
8x
            "validation_errors_count": len(validationErrors),
1525
8x
            "validation_errors":       strings.Join(validationErrors, "; "),
1526
8x
        })
1527
8x
        span.SetAttributes(attribute.String("parse.result", "validation_errors"), attribute.String("errors", strings.Join(validationErrors, "; ")))
1528
8x
    }
1529

1530
13x
    if len(result) == 0 {
1531
7x
        span.SetAttributes(attribute.String("parse.result", "no_valid_questions"), attribute.Int("total_questions", len(questions)), attribute.Int("skipped_count", skippedCount))
1532
7x
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "AI provider returned only invalid or empty questions (total: %d, skipped: %d)", len(questions), skippedCount)
1533
7x
    }
1534

1535
6x
    span.SetAttributes(attribute.String("parse.result", "success"), attribute.Int("valid_questions", len(result)), attribute.Int("total_questions", len(questions)), attribute.Int("skipped_count", skippedCount))
1536
6x
    return result, nil
1537
}
1538

1539
// createQuestionFromData creates a Question from parsed JSON data
1540
2035x
func (s *AIService) createQuestionFromData(ctx context.Context, data map[string]interface{}, language, level string, qType models.QuestionType) (result0 *models.Question, err error) {
1541
2035x
    if s == nil {
1542
1x
        return nil, contextutils.WrapError(contextutils.ErrInternalError, "AIService instance is nil")
1543
1x
    }
1544
2033x
    _, span := observability.TraceAIFunction(ctx, "create_question_from_data",
1545
2033x
        observability.AttributeQuestionType(qType),
1546
2033x
        observability.AttributeLanguage(language),
1547
2033x
        observability.AttributeLevel(level),
1548
2033x
        attribute.Int("data.fields", len(data)),
1549
2033x
    )
1550
2033x
    defer observability.FinishSpan(span, &err)
1551
2033x

1552
2033x
    if data == nil {
1553
1x
        span.SetAttributes(attribute.String("creation.result", "nil_data"))
1554
1x
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "question data is nil")
1555
1x
    }
1556

1557
    // Validate required parameters
1558
2031x
    if language == "" {
1559
        span.SetAttributes(attribute.String("creation.result", "empty_language"))
1560
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "language cannot be empty")
1561
    }
1562
2031x
    if level == "" {
1563
        span.SetAttributes(attribute.String("creation.result", "empty_level"))
1564
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "level cannot be empty")
1565
    }
1566

1567
2031x
    if ok, errMsg := s.validateQuestionContent(ctx, qType, data); !ok {
1568
9x
        missingFields := []string{}
1569
9x
        for k, v := range data {
1570
30x
            if v == nil || v == "" {
1571
2x
                missingFields = append(missingFields, k)
1572
2x
            }
1573
        }
1574
9x
        if len(missingFields) > 0 {
1575
2x
            span.SetAttributes(attribute.String("creation.result", "validation_failed_with_missing_fields"), attribute.String("missing_fields", strings.Join(missingFields, ",")))
1576
2x
            return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "invalid question content structure: %s. Missing or empty fields: %v", errMsg, missingFields)
1577
2x
        }
1578
7x
        span.SetAttributes(attribute.String("creation.result", "validation_failed"), attribute.String("error", errMsg))
1579
7x
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "invalid question content structure: %s", errMsg)
1580
    }
1581

1582
    // Defensive: For reading comprehension, check passage, question, options, correct_answer
1583
2013x
    if qType == models.ReadingComprehension {
1584
1x
        if _, ok := data["passage"].(string); !ok {
1585
            span.SetAttributes(attribute.String("creation.result", "reading_missing_passage"))
1586
            return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "reading comprehension question missing or invalid 'passage' field")
1587
        }
1588
1x
        if _, ok := data["question"].(string); !ok {
1589
            span.SetAttributes(attribute.String("creation.result", "reading_missing_question"))
1590
            return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "reading comprehension question missing or invalid 'question' field")
1591
        }
1592
1x
        options, ok := data["options"].([]interface{})
1593
1x
        if !ok || len(options) != 4 {
1594
            span.SetAttributes(attribute.String("creation.result", "reading_invalid_options"))
1595
            return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "reading comprehension question missing or invalid 'options' field (must be array of 4 strings)")
1596
        }
1597
1x
        for i, opt := range options {
1598
4x
            if _, ok := opt.(string); !ok {
1599
                span.SetAttributes(attribute.String("creation.result", "reading_invalid_option_type"), attribute.Int("option_index", i))
1600
                return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "reading comprehension question 'options' must be array of strings, found invalid type at index %d", i)
1601
            }
1602
        }
1603
1x
        if _, ok := data["correct_answer"]; !ok {
1604
            span.SetAttributes(attribute.String("creation.result", "reading_missing_correct_answer"))
1605
            return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "reading comprehension question missing 'correct_answer' field")
1606
        }
1607
    }
1608

1609
    // Parse correct_answer as index (integer)
1610
2013x
    var correctAnswerIndex int
1611
2013x
    if correctAnswerRaw, exists := data["correct_answer"]; exists {
1612
2013x
        switch v := correctAnswerRaw.(type) {
1613
        case int:
1614
            correctAnswerIndex = v
1615
2013x
        case float64:
1616
2013x
            correctAnswerIndex = int(v)
1617
        case string:
1618
            // Handle string indices like "0", "1", "2", "3"
1619
            if idx, err := strconv.Atoi(v); err == nil {
1620
                correctAnswerIndex = idx
1621
            } else {
1622
                // Handle answer text - find index in options
1623
                if options, ok := data["options"].([]interface{}); ok {
1624
                    found := false
1625
                    for i, opt := range options {
1626
                        if optStr, ok := opt.(string); ok && optStr == v {
1627
                            correctAnswerIndex = i
1628
                            found = true
1629
                            break
1630
                        }
1631
                    }
1632
                    if !found {
1633
                        span.SetAttributes(attribute.String("creation.result", "correct_answer_not_found_in_options"))
1634
                        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "correct_answer '%s' not found in options", v)
1635
                    }
1636
                } else {
1637
                    span.SetAttributes(attribute.String("creation.result", "no_options_for_text_answer"))
1638
                    return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "correct_answer is text '%s' but no options available to match against", v)
1639
                }
1640
            }
1641
        default:
1642
            span.SetAttributes(attribute.String("creation.result", "invalid_correct_answer_type"), attribute.String("type", fmt.Sprintf("%T", v)))
1643
            return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "invalid correct_answer type: %T", v)
1644
        }
1645
    } else {
1646
        span.SetAttributes(attribute.String("creation.result", "missing_correct_answer"))
1647
        return nil, contextutils.WrapError(contextutils.ErrAIResponseInvalid, "missing correct_answer field")
1648
    }
1649

1650
    // Validate correct answer index
1651
2013x
    if options, ok := data["options"].([]interface{}); ok {
1652
2013x
        if correctAnswerIndex < 0 || correctAnswerIndex >= len(options) {
1653
            span.SetAttributes(attribute.String("creation.result", "invalid_correct_answer_index"), attribute.Int("index", correctAnswerIndex), attribute.Int("options_count", len(options)))
1654
            return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "correct_answer index %d is out of range (0-%d)", correctAnswerIndex, len(options)-1)
1655
        }
1656
    }
1657

1658
    // Note: Removed backend shuffling logic - frontend handles shuffling
1659
    // This prevents mismatch between backend and frontend answer indices
1660

1661
    // Get explanation or provide default
1662
2013x
    explanation, _ := data["explanation"].(string)
1663
2013x
    if explanation == "" {
1664
1005x
        // Provide a default explanation based on question type
1665
1005x
        switch qType {
1666
1005x
        case models.Vocabulary:
1667
1005x
            explanation = "This vocabulary question tests your knowledge of words in context."
1668
        case models.ReadingComprehension:
1669
            explanation = "This reading comprehension question tests your understanding of the passage."
1670
        case models.FillInBlank:
1671
            explanation = "This fill-in-the-blank question tests your grammar and vocabulary knowledge."
1672
        case models.QuestionAnswer:
1673
            explanation = "This question tests your conversational and practical language skills."
1674
        default:
1675
            explanation = "This question tests your language skills."
1676
        }
1677
        // Add the explanation to the data for schema validation
1678
1005x
        data["explanation"] = explanation
1679
    }
1680

1681
2013x
    question := &models.Question{
1682
2013x
        Type:            qType,
1683
2013x
        Language:        language,
1684
2013x
        Level:           level,
1685
2013x
        DifficultyScore: s.getDifficultyScore(level),
1686
2013x
        Content:         data,
1687
2013x
        CorrectAnswer:   correctAnswerIndex,
1688
2013x
        Explanation:     explanation,
1689
2013x
        CreatedAt:       time.Now(),
1690
2013x
    }
1691
2013x

1692
2013x
    span.SetAttributes(attribute.String("creation.result", "success"))
1693
2013x
    return question, nil
1694
}
1695

1696
3x
func (s *AIService) parseQuestionResponse(ctx context.Context, response, language, level string, qType models.QuestionType, provider string) (result0 *models.Question, err error) {
1697
3x
    _, span := observability.TraceAIFunction(ctx, "parse_question_response",
1698
3x
        observability.AttributeQuestionType(qType),
1699
3x
        observability.AttributeLanguage(language),
1700
3x
        observability.AttributeLevel(level),
1701
3x
        attribute.String("ai.provider", provider),
1702
3x
        attribute.Int("response.length", len(response)),
1703
3x
    )
1704
3x
    defer observability.FinishSpan(span, &err)
1705
3x
    // Clean the response to handle markdown code blocks for providers without grammar support
1706
3x
    cleanedResponse := s.cleanJSONResponse(ctx, response, provider)
1707
3x

1708
3x
    // With grammar field enforcement, we should get clean JSON directly
1709
3x
    // No need for complex extraction - just parse the response directly
1710
3x
    var data map[string]interface{}
1711
3x
    if err := json.Unmarshal([]byte(cleanedResponse), &data); err != nil {
1712
2x
        s.logger.Error(ctx, "Failed to parse JSON response", err, map[string]interface{}{
1713
2x
            "raw_response": response,
1714
2x
        })
1715
2x
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "failed to parse AI response as JSON: %w", err)
1716
2x
    }
1717

1718
1x
    question, err := s.createQuestionFromData(ctx, data, language, level, qType)
1719
1x
    if err != nil {
1720
        s.logger.Error(ctx, "Failed to create question from data", err, map[string]interface{}{
1721
            "raw_question_data":   data,
1722
            "full_model_response": response,
1723
        })
1724
        return nil, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "failed to create question: %w", err)
1725
    }
1726
1x
    valid, err := s.ValidateQuestionSchema(ctx, qType, question)
1727
1x
    if err != nil {
1728
        s.logger.Error(ctx, "Schema validation error for question", err, nil)
1729
    }
1730
1x
    if !valid {
1731
        SchemaValidationMu.Lock()
1732
        SchemaValidationFailures[qType]++
1733
        if err != nil {
1734
            SchemaValidationFailureDetails[qType] = append(SchemaValidationFailureDetails[qType], err.Error())
1735
        } else {
1736
            SchemaValidationFailureDetails[qType] = append(SchemaValidationFailureDetails[qType], "validation failed")
1737
        }
1738
        if len(SchemaValidationFailureDetails[qType]) > 10 {
1739
            SchemaValidationFailureDetails[qType] = SchemaValidationFailureDetails[qType][len(SchemaValidationFailureDetails[qType])-10:]
1740
        }
1741
        SchemaValidationMu.Unlock()
1742
    }
1743
1x
    return question, nil
1744
}
1745

1746
2061x
func (s *AIService) getDifficultyScore(level string) float64 {
1747
2061x
    // Look up the level in the language levels configuration
1748
2061x
    if s.cfg != nil && s.cfg.LanguageLevels != nil {
1749
24x
        for _, langConfig := range s.cfg.LanguageLevels {
1750
84x
            for i, lvl := range langConfig.Levels {
1751
364x
                if lvl == level {
1752
21x
                    // Return a score based on the level's position (0.0 to 1.0)
1753
21x
                    return float64(i) / float64(len(langConfig.Levels)-1)
1754
21x
                }
1755
            }
1756
        }
1757
    }
1758
    // Default to middle difficulty if level not found
1759
2019x
    return 0.5
1760
}
1761

1762
2031x
func (s *AIService) validateQuestionContent(ctx context.Context, qType models.QuestionType, content map[string]interface{}) (bool, string) {
1763
2031x
    _, span := observability.TraceAIFunction(ctx, "validate_question_content",
1764
2031x
        observability.AttributeQuestionType(qType),
1765
2031x
        attribute.Int("content.fields", len(content)),
1766
2031x
    )
1767
2031x
    defer span.End()
1768
2031x

1769
2031x
    // Validate input parameters
1770
2031x
    if content == nil {
1771
        span.SetAttributes(attribute.String("validation.result", "nil_content"))
1772
        return false, "question content cannot be nil"
1773
    }
1774

1775
2031x
    requiredFields := make(map[string]func(interface{}) bool)
1776
2031x
    isString := func(v interface{}) bool {
1777
4057x
        if v == nil {
1778
1x
            return false
1779
1x
        }
1780
4055x
        _, ok := v.(string)
1781
4055x
        return ok && v.(string) != ""
1782
    }
1783
2031x
    isStringSlice := func(v interface{}) bool {
1784
2027x
        if v == nil {
1785
2x
            return false
1786
2x
        }
1787
2023x
        if slice, ok := v.([]interface{}); ok {
1788
2023x
            if len(slice) < 4 {
1789
1x
                return false
1790
1x
            }
1791
2021x
            for _, item := range slice {
1792
8084x
                if item == nil {
1793
                    return false
1794
                }
1795
8084x
                if _, ok := item.(string); !ok {
1796
                    return false
1797
                }
1798
8084x
                if item.(string) == "" {
1799
                    return false
1800
                }
1801
            }
1802
2021x
            return true
1803
        }
1804
        return false
1805
    }
1806
2031x
    isCorrectAnswer := func(v interface{}) bool {
1807
1x
        if v == nil {
1808
            return false
1809
        }
1810
1x
        switch val := v.(type) {
1811
        case int:
1812
            return val >= 0
1813
1x
        case float64:
1814
1x
            return val >= 0 && val == float64(int(val)) // Must be whole number
1815
        case string:
1816
            // Accept string indices like "0", "1", "2", "3" or answer text
1817
            if _, err := strconv.Atoi(val); err == nil {
1818
                return true
1819
            }
1820
            // Or accept answer text that matches one of the options
1821
            if options, ok := content["options"].([]interface{}); ok {
1822
                for _, opt := range options {
1823
                    if optStr, ok := opt.(string); ok && optStr == val {
1824
                        return true
1825
                    }
1826
                }
1827
            }
1828
            return false
1829
        default:
1830
            return false
1831
        }
1832
    }
1833

1834
2031x
    switch qType {
1835
2029x
    case models.Vocabulary:
1836
2029x
        requiredFields["sentence"] = isString
1837
2029x
        requiredFields["question"] = isString
1838
2029x
        requiredFields["options"] = isStringSlice
1839
2029x
        for field, validator := range requiredFields {
1840
6078x
            if !validator(content[field]) {
1841
5x
                span.SetAttributes(attribute.String("validation.result", "field_validation_failed"), attribute.String("field", field))
1842
5x
                return false, fmt.Sprintf("[Vocabulary] Validation failed for field '%s': %v", field, content[field])
1843
5x
            }
1844
        }
1845
2019x
        sentence, _ := content["sentence"].(string)
1846
2019x
        targetWord, _ := content["question"].(string)
1847
2019x
        options, _ := content["options"].([]interface{})
1848
2019x
        if sentence == "" || targetWord == "" || len(options) != 4 {
1849
            span.SetAttributes(attribute.String("validation.result", "vocabulary_structure_failed"))
1850
            return false, "[Vocabulary] Validation failed: missing or invalid sentence/question/options"
1851
        }
1852
2019x
        if !strings.Contains(sentence, targetWord) {
1853
4x
            span.SetAttributes(attribute.String("validation.result", "vocabulary_word_not_found"))
1854
4x
            return false, fmt.Sprintf("[Vocabulary] Validation failed: question '%s' not found in sentence '%s'", targetWord, sentence)
1855
4x
        }
1856
2011x
        span.SetAttributes(attribute.String("validation.result", "valid"))
1857
2011x
        return true, ""
1858

1859
1x
    case models.ReadingComprehension:
1860
1x
        requiredFields["passage"] = isString
1861
1x
        requiredFields["question"] = isString
1862
1x
        requiredFields["options"] = isStringSlice
1863
1x
        requiredFields["correct_answer"] = isCorrectAnswer
1864
1x
        for field, validator := range requiredFields {
1865
4x
            if !validator(content[field]) {
1866
                span.SetAttributes(attribute.String("validation.result", "field_validation_failed"), attribute.String("field", field))
1867
                return false, fmt.Sprintf("[ReadingComprehension] Validation failed for field '%s': %v", field, content[field])
1868
            }
1869
        }
1870
1x
        passage, _ := content["passage"].(string)
1871
1x
        if passage == "" {
1872
            span.SetAttributes(attribute.String("validation.result", "reading_passage_empty"))
1873
            return false, "[ReadingComprehension] Validation failed: passage cannot be empty"
1874
        }
1875
1x
        span.SetAttributes(attribute.String("validation.result", "valid"))
1876
1x
        return true, ""
1877

1878
    case models.FillInBlank:
1879
        // Fill-in-blank questions now use multiple choice format like all other types
1880
        requiredFields["question"] = isString
1881
        requiredFields["options"] = isStringSlice
1882
        requiredFields["correct_answer"] = isCorrectAnswer
1883
        for field, validator := range requiredFields {
1884
            if !validator(content[field]) {
1885
                span.SetAttributes(attribute.String("validation.result", "field_validation_failed"), attribute.String("field", field))
1886
                return false, fmt.Sprintf("[FillInBlank] Validation failed for field '%s': %v", field, content[field])
1887
            }
1888
        }
1889
        span.SetAttributes(attribute.String("validation.result", "valid"))
1890
        return true, ""
1891

1892
    case models.QuestionAnswer:
1893
        // Question-answer questions now use multiple choice format like all other types
1894
        requiredFields["question"] = isString
1895
        requiredFields["options"] = isStringSlice
1896
        requiredFields["correct_answer"] = isCorrectAnswer
1897
        for field, validator := range requiredFields {
1898
            if !validator(content[field]) {
1899
                span.SetAttributes(attribute.String("validation.result", "field_validation_failed"), attribute.String("field", field))
1900
                return false, fmt.Sprintf("[QuestionAnswer] Validation failed for field '%s': %v", field, content[field])
1901
            }
1902
        }
1903
        span.SetAttributes(attribute.String("validation.result", "valid"))
1904
        return true, ""
1905
    }
1906

1907
    // If we reach here, it's an unknown question type
1908
    span.SetAttributes(attribute.String("validation.result", "unknown_type"))
1909
    return false, fmt.Sprintf("unknown question type: %v", qType)
1910
}
1911

1912
// GetConcurrencyStats returns current concurrency metrics
1913
9x
func (s *AIService) GetConcurrencyStats() ConcurrencyStats {
1914
9x
    s.statsMu.RLock()
1915
9x
    s.concurrencyMu.RLock()
1916
9x
    defer s.statsMu.RUnlock()
1917
9x
    defer s.concurrencyMu.RUnlock()
1918
9x

1919
9x
    // Count active requests globally and per user
1920
9x
    queuedRequests := 0 // Currently we don't queue, we fail fast
1921
9x

1922
9x
    userActiveCount := make(map[string]int)
1923
9x
    for username, count := range s.userRequestCount {
1924
11x
        if count > 0 {
1925
1x
            userActiveCount[username] = count
1926
1x
        }
1927
    }
1928

1929
9x
    return ConcurrencyStats{
1930
9x
        ActiveRequests:  s.activeRequests,
1931
9x
        MaxConcurrent:   s.maxConcurrent,
1932
9x
        QueuedRequests:  queuedRequests,
1933
9x
        TotalRequests:   s.totalRequests,
1934
9x
        UserActiveCount: userActiveCount,
1935
9x
        MaxPerUser:      s.maxPerUser,
1936
9x
    }
1937
}
1938

1939
// acquireGlobalSlot attempts to acquire a global concurrency slot
1940
15x
func (s *AIService) acquireGlobalSlot(ctx context.Context) error {
1941
15x
    select {
1942
11x
    case s.globalSemaphore <- struct{}{}:
1943
11x
        return nil
1944
2x
    case <-ctx.Done():
1945
2x
        return contextutils.WrapErrorf(contextutils.ErrTimeout, "request cancelled while waiting for global AI slot: %w", ctx.Err())
1946
1x
    default:
1947
1x
        return contextutils.WrapErrorf(contextutils.ErrServiceUnavailable, "AI service at capacity (%d concurrent requests), please try again", s.maxConcurrent)
1948
    }
1949
}
1950

1951
// releaseGlobalSlot releases a global concurrency slot
1952
11x
func (s *AIService) releaseGlobalSlot(ctx context.Context) {
1953
11x
    s.concurrencyMu.Lock()
1954
11x
    defer s.concurrencyMu.Unlock()
1955
11x

1956
11x
    select {
1957
11x
    case <-s.globalSemaphore:
1958
11x
        // Successfully released a slot
1959
11x
        s.statsMu.Lock()
1960
11x
        if s.activeRequests > 0 {
1961
5x
            s.activeRequests--
1962
5x
        }
1963
11x
        s.statsMu.Unlock()
1964
    default:
1965
        // No slot was acquired
1966
        s.logger.Warn(ctx, "WARNING: Attempted to release global AI slot but none were acquired", nil)
1967
    }
1968
}
1969

1970
// acquireUserSlot acquires a user-specific concurrency slot
1971
15x
func (s *AIService) acquireUserSlot(_ context.Context, username string) error {
1972
15x
    s.concurrencyMu.Lock()
1973
15x
    defer s.concurrencyMu.Unlock()
1974
15x

1975
15x
    currentCount := s.userRequestCount[username]
1976
15x
    if currentCount >= s.maxPerUser {
1977
1x
        return contextutils.WrapErrorf(contextutils.ErrServiceUnavailable, "user concurrency limit exceeded for %s: %d/%d", username, currentCount, s.maxPerUser)
1978
1x
    }
1979

1980
13x
    s.userRequestCount[username] = currentCount + 1
1981
13x
    return nil
1982
}
1983

1984
// releaseUserSlot releases a user-specific concurrency slot
1985
13x
func (s *AIService) releaseUserSlot(ctx context.Context, username string) {
1986
13x
    s.concurrencyMu.Lock()
1987
13x
    defer s.concurrencyMu.Unlock()
1988
13x

1989
13x
    currentCount := s.userRequestCount[username]
1990
13x
    if currentCount > 0 {
1991
13x
        s.userRequestCount[username] = currentCount - 1
1992
13x
    } else {
1993
        s.logger.Warn(ctx, "WARNING: Attempted to release user AI slot but none were acquired", map[string]interface{}{
1994
            "username": username,
1995
        })
1996
    }
1997
}
1998

1999
// incrementTotalRequests increments the total request counter
2000
7x
func (s *AIService) incrementTotalRequests() {
2001
7x
    s.statsMu.Lock()
2002
7x
    defer s.statsMu.Unlock()
2003
7x
    s.totalRequests++
2004
7x
}
2005

2006
// withConcurrencyControl wraps an AI operation with concurrency limits
2007
9x
func (s *AIService) withConcurrencyControl(ctx context.Context, username string, operation func() error) error {
2008
9x
    // Check if service is shutting down
2009
9x
    if s.isShutdown() {
2010
1x
        return contextutils.WrapError(contextutils.ErrServiceUnavailable, "AI service is shutting down")
2011
1x
    }
2012

2013
    // Increment total request counter
2014
7x
    s.incrementTotalRequests()
2015
7x

2016
7x
    // Acquire global slot
2017
7x
    if err := s.acquireGlobalSlot(ctx); err != nil {
2018
2x
        return err
2019
2x
    }
2020

2021
    // Track active request
2022
5x
    s.statsMu.Lock()
2023
5x
    s.activeRequests++
2024
5x
    s.statsMu.Unlock()
2025
5x

2026
5x
    defer func() {
2027
5x
        s.releaseGlobalSlot(ctx)
2028
5x
    }()
2029

2030
    // Acquire per-user slot
2031
5x
    if err := s.acquireUserSlot(ctx, username); err != nil {
2032
        return err
2033
    }
2034
5x
    defer s.releaseUserSlot(ctx, username)
2035
5x

2036
5x
    // Execute the actual operation
2037
5x
    return operation()
2038
}
2039

2040
// supportsGrammarField checks if the provider supports the grammar field
2041
77x
func (s *AIService) supportsGrammarField(provider string) bool {
2042
77x
    // Check if the provider supports grammar field
2043
77x
    if s.cfg.Providers == nil {
2044
19x
        return false
2045
19x
    }
2046

2047
39x
    for _, providerConfig := range s.cfg.Providers {
2048
81x
        if providerConfig.Code == provider {
2049
32x
            return providerConfig.SupportsGrammar
2050
32x
        }
2051
    }
2052
7x
    return false
2053
}
2054

2055
// getQuestionBatchSize returns the maximum number of questions that can be generated in a single request for the given provider
2056
5x
func (s *AIService) getQuestionBatchSize(provider string) int {
2057
5x
    // Get the batch size for the provider
2058
5x
    if s.cfg.Providers == nil {
2059
        return 1 // Default batch size
2060
    }
2061

2062
5x
    for _, p := range s.cfg.Providers {
2063
10x
        if p.Code == provider {
2064
3x
            if p.QuestionBatchSize > 0 {
2065
3x
                return p.QuestionBatchSize
2066
3x
            }
2067
            break
2068
        }
2069
    }
2070
2x
    return 1 // Default batch size
2071
}
2072

2073
// GetQuestionBatchSize returns the maximum number of questions that can be generated in a single request for the given provider
2074
func (s *AIService) GetQuestionBatchSize(provider string) int {
2075
    return s.getQuestionBatchSize(provider)
2076
}
2077

2078
// VarietyService returns the variety service used by the AI service
2079
1x
func (s *AIService) VarietyService() *VarietyService {
2080
1x
    return s.varietyService
2081
1x
}
2082

2083
// TemplateManager exposes template rendering and example loading for prompts
2084
func (s *AIService) TemplateManager() *AITemplateManager {
2085
    return s.templateManager
2086
}
2087

2088
// SupportsGrammarField reports whether the provider supports the grammar field
2089
func (s *AIService) SupportsGrammarField(provider string) bool {
2090
    return s.supportsGrammarField(provider)
2091
}
2092

2093
// CallWithPrompt sends a raw prompt (and optional grammar) to the provider and returns the response
2094
func (s *AIService) CallWithPrompt(ctx context.Context, userConfig *UserAIConfig, prompt, grammar string) (string, error) {
2095
    return s.callOpenAI(ctx, userConfig, prompt, grammar)
2096
}
2097


			
quizapp internal services worker_service.go
78.6%
Statements
11/14
1
// Package services provides embedded templates for AI service prompts
2
package services
3

4
import (
5
    "embed"
6
    "fmt"
7
    "strings"
8
    "text/template"
9

10
    contextutils "quizapp/internal/utils"
11
)
12

13
//go:embed templates/*.tmpl
14
var aiTemplatesFS embed.FS
15

16
//go:embed templates/examples/*.json
17
var exampleFilesFS embed.FS
18

19
// Template names as constants
20
const (
21
    BatchQuestionPromptTemplate   = "batch_question_prompt.tmpl"
22
    ChatPromptTemplate            = "chat_prompt.tmpl"
23
    JSONStructureGuidanceTemplate = "json_structure_guidance.tmpl"
24
    AIFixPromptTemplate           = "ai_fix_prompt.tmpl"
25
)
26

27
// AITemplateData holds data for rendering AI prompt templates
28
type AITemplateData struct {
29
    // Common fields
30
    Language              string
31
    Level                 string
32
    QuestionType          string
33
    Topic                 string
34
    RecentQuestionHistory []string
35
    ReportReasons         []string
36
    Count                 int // For batch generation
37

38
    // Variety fields for question generation
39
    TopicCategory      string
40
    GrammarFocus       string
41
    VocabularyDomain   string
42
    Scenario           string
43
    StyleModifier      string
44
    DifficultyModifier string
45
    TimeContext        string
46

47
    // Schema and formatting
48
    SchemaForPrompt     string // for direct inclusion in prompt for non-grammar providers
49
    ExampleContent      string // for including example in prompt
50
    CurrentQuestionJSON string // the actual question JSON to pass into ai-fix prompt
51
    AdditionalContext   string // optional freeform context provided by admin when requesting AI fix
52

53
    // Explanation specific
54
    Question      string
55
    UserAnswer    string
56
    CorrectAnswer string // The text of the correct answer for explanations
57

58
    // Chat specific
59
    Passage             string
60
    Options             []string
61
    IsCorrect           *bool
62
    ConversationHistory []ChatMessage
63
    UserMessage         string
64

65
    // Priority-aware generation fields (NEW)
66
    UserWeakAreas        []string
67
    HighPriorityTopics   []string
68
    GapAnalysis          map[string]int
69
    FocusOnWeakAreas     bool
70
    FreshQuestionRatio   float64
71
    PriorityDistribution map[string]int
72
}
73

74
// ChatMessage represents a chat message for templates
75
type ChatMessage struct {
76
    Role    string
77
    Content string
78
}
79

80
// AITemplateManager manages AI prompt templates
81
type AITemplateManager struct {
82
    templates *template.Template
83
}
84

85
// NewAITemplateManager creates a new template manager
86
62x
func NewAITemplateManager() (result0 *AITemplateManager, err error) {
87
62x
    templates, err := template.New("").ParseFS(aiTemplatesFS, "templates/*.tmpl")
88
62x
    if err != nil {
89
        return nil, err
90
    }
91

92
62x
    return &AITemplateManager{
93
62x
        templates: templates,
94
62x
    }, nil
95
}
96

97
// RenderTemplate renders a template with the given data
98
31x
func (tm *AITemplateManager) RenderTemplate(templateName string, data AITemplateData) (result0 string, err error) {
99
31x
    var buf strings.Builder
100
31x
    err = tm.templates.ExecuteTemplate(&buf, templateName, data)
101
31x
    if err != nil {
102
        return "", err
103
    }
104
31x
    return buf.String(), nil
105
}
106

107
// LoadExample loads the example JSON for a specific question type
108
19x
func (tm *AITemplateManager) LoadExample(questionType string) (result0 string, err error) {
109
19x
    examplePath := fmt.Sprintf("templates/examples/%s_example.json", questionType)
110
19x
    content, err := exampleFilesFS.ReadFile(examplePath)
111
19x
    if err != nil {
112
        return "", contextutils.WrapErrorf(contextutils.ErrInternalError, "failed to load example for %s: %w", questionType, err)
113
    }
114
19x
    return string(content), nil
115
}
116


			
quizapp internal services worker_service.go
26.2%
Statements
27/103
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "time"
8

9
    "go.opentelemetry.io/otel/attribute"
10
    "go.opentelemetry.io/otel/codes"
11
    "go.opentelemetry.io/otel/trace"
12

13
    "quizapp/internal/observability"
14
)
15

16
// CleanupService handles database maintenance and cleanup tasks
17
type CleanupService struct {
18
    db     *sql.DB
19
    logger *observability.Logger
20
}
21

22
// NewCleanupServiceWithLogger creates a new cleanup service with logger
23
13x
func NewCleanupServiceWithLogger(db *sql.DB, logger *observability.Logger) *CleanupService {
24
13x
    return &CleanupService{
25
13x
        db:     db,
26
13x
        logger: logger,
27
13x
    }
28
13x
}
29

30
// CleanupLegacyQuestionTypes removes questions with unsupported question types
31
8x
func (c *CleanupService) CleanupLegacyQuestionTypes(ctx context.Context) (err error) {
32
8x
    ctx, span := observability.TraceCleanupFunction(ctx, "cleanup_legacy_question_types")
33
8x
    defer func() {
34
8x
        if err != nil {
35
2x
            span.RecordError(err, trace.WithStackTrace(true))
36
2x
            span.SetStatus(codes.Error, err.Error())
37
2x
        }
38
8x
        span.End()
39
    }()
40

41
    // Check if database is available
42
8x
    if c.db == nil {
43
1x
        return errors.New("database connection not available")
44
1x
    }
45

46
    // Get count of legacy questions first
47
7x
    var count int
48
7x
    err = c.db.QueryRowContext(ctx, `
49
7x
        SELECT COUNT(*)
50
7x
        FROM questions
51
7x
        WHERE type NOT IN ('vocabulary', 'fill_blank', 'qa', 'reading_comprehension')
52
7x
    `).Scan(&count)
53
7x
    if err != nil {
54
1x
        span.SetAttributes(attribute.String("error", err.Error()))
55
1x
        return err
56
1x
    }
57

58
6x
    span.SetAttributes(attribute.Int("cleanup.legacy_questions_count", count))
59
6x

60
6x
    if count == 0 {
61
3x
        c.logger.Info(ctx, "No legacy question types found to cleanup", map[string]interface{}{})
62
3x
        span.SetAttributes(attribute.String("cleanup.result", "no_legacy_questions"))
63
3x
        return nil
64
3x
    }
65

66
3x
    c.logger.Info(ctx, "Found questions with legacy types to cleanup", map[string]interface{}{"count": count})
67
3x

68
3x
    // Delete questions with unsupported types
69
3x
    result, err := c.db.ExecContext(ctx, `
70
3x
        DELETE FROM questions
71
3x
        WHERE type NOT IN ('vocabulary', 'fill_blank', 'qa', 'reading_comprehension')
72
3x
    `)
73
3x
    if err != nil {
74
        span.SetAttributes(attribute.String("error", err.Error()))
75
        return err
76
    }
77

78
3x
    rowsAffected, err := result.RowsAffected()
79
3x
    if err != nil {
80
        span.SetAttributes(attribute.String("error", err.Error()))
81
        return err
82
    }
83

84
3x
    span.SetAttributes(
85
3x
        attribute.Int64("cleanup.rows_affected", rowsAffected),
86
3x
        attribute.String("cleanup.result", "success"),
87
3x
    )
88
3x

89
3x
    c.logger.Info(ctx, "Successfully cleaned up questions with legacy types", map[string]interface{}{"rows_affected": rowsAffected})
90
3x
    return nil
91
}
92

93
// CleanupOrphanedResponses removes user responses for questions that no longer exist
94
func (c *CleanupService) CleanupOrphanedResponses(ctx context.Context) (err error) {
95
    ctx, span := observability.TraceCleanupFunction(ctx, "cleanup_orphaned_responses")
96
    defer func() {
97
        if err != nil {
98
            span.RecordError(err, trace.WithStackTrace(true))
99
            span.SetStatus(codes.Error, err.Error())
100
        }
101
        span.End()
102
    }()
103

104
    // Check if database is available
105
    if c.db == nil {
106
        return errors.New("database connection not available")
107
    }
108

109
    var count int
110
    err = c.db.QueryRowContext(ctx, `
111
        SELECT COUNT(*)
112
        FROM user_responses ur
113
        LEFT JOIN questions q ON ur.question_id = q.id
114
        WHERE q.id IS NULL
115
    `).Scan(&count)
116
    if err != nil {
117
        span.SetAttributes(attribute.String("error", err.Error()))
118
        return err
119
    }
120

121
    span.SetAttributes(attribute.Int("cleanup.orphaned_responses_count", count))
122

123
    if count == 0 {
124
        c.logger.Info(ctx, "No orphaned responses found to cleanup", map[string]interface{}{})
125
        span.SetAttributes(attribute.String("cleanup.result", "no_orphaned_responses"))
126
        return nil
127
    }
128

129
    c.logger.Info(ctx, "Found orphaned responses to cleanup", map[string]interface{}{"count": count})
130

131
    result, err := c.db.ExecContext(ctx, `
132
        DELETE FROM user_responses
133
        WHERE question_id NOT IN (SELECT id FROM questions)
134
    `)
135
    if err != nil {
136
        span.SetAttributes(attribute.String("error", err.Error()))
137
        return err
138
    }
139

140
    rowsAffected, err := result.RowsAffected()
141
    if err != nil {
142
        span.SetAttributes(attribute.String("error", err.Error()))
143
        return err
144
    }
145

146
    span.SetAttributes(
147
        attribute.Int64("cleanup.rows_affected", rowsAffected),
148
        attribute.String("cleanup.result", "success"),
149
    )
150

151
    c.logger.Info(ctx, "Successfully cleaned up orphaned responses", map[string]interface{}{"rows_affected": rowsAffected})
152
    return nil
153
}
154

155
// RunFullCleanup performs all cleanup operations
156
func (c *CleanupService) RunFullCleanup(ctx context.Context) (err error) {
157
    ctx, span := observability.TraceCleanupFunction(ctx, "run_full_cleanup")
158
    defer func() {
159
        if err != nil {
160
            span.RecordError(err, trace.WithStackTrace(true))
161
            span.SetStatus(codes.Error, err.Error())
162
        }
163
        span.End()
164
    }()
165

166
    span.SetAttributes(attribute.String("cleanup.start_time", time.Now().Format(time.RFC3339)))
167

168
    c.logger.Info(ctx, "Starting database cleanup", map[string]interface{}{"start_time": time.Now().Format(time.RFC3339)})
169

170
    if err = c.CleanupLegacyQuestionTypes(ctx); err != nil {
171
        c.logger.Error(ctx, "Failed to cleanup legacy question types", err, map[string]interface{}{})
172
        span.SetAttributes(attribute.String("error", err.Error()))
173
        return err
174
    }
175

176
    if err := c.CleanupOrphanedResponses(ctx); err != nil {
177
        c.logger.Error(ctx, "Failed to cleanup orphaned responses", err, map[string]interface{}{})
178
        span.SetAttributes(attribute.String("error", err.Error()))
179
        return err
180
    }
181

182
    span.SetAttributes(
183
        attribute.String("cleanup.end_time", time.Now().Format(time.RFC3339)),
184
        attribute.String("cleanup.result", "success"),
185
    )
186

187
    c.logger.Info(ctx, "Database cleanup completed successfully", map[string]interface{}{"end_time": time.Now().Format(time.RFC3339)})
188
    return nil
189
}
190

191
// GetCleanupStats returns statistics about cleanup operations
192
func (c *CleanupService) GetCleanupStats(ctx context.Context) (result0 map[string]int, err error) {
193
    ctx, span := observability.TraceCleanupFunction(ctx, "get_cleanup_stats")
194
    defer func() {
195
        if err != nil {
196
            span.RecordError(err, trace.WithStackTrace(true))
197
            span.SetStatus(codes.Error, err.Error())
198
        }
199
        span.End()
200
    }()
201

202
    // Check if database is available
203
    if c.db == nil {
204
        return nil, errors.New("database connection not available")
205
    }
206

207
    stats := make(map[string]int)
208

209
    // Count legacy question types
210
    var legacyCount int
211
    err = c.db.QueryRowContext(ctx, `
212
        SELECT COUNT(*)
213
        FROM questions
214
        WHERE type NOT IN ('vocabulary', 'fill_blank', 'qa', 'reading_comprehension')
215
    `).Scan(&legacyCount)
216
    if err != nil {
217
        span.SetAttributes(attribute.String("error", err.Error()))
218
        return nil, err
219
    }
220
    stats["legacy_questions"] = legacyCount
221

222
    // Count orphaned responses
223
    var orphanedCount int
224
    err = c.db.QueryRowContext(ctx, `
225
        SELECT COUNT(*)
226
        FROM user_responses ur
227
        LEFT JOIN questions q ON ur.question_id = q.id
228
        WHERE q.id IS NULL
229
    `).Scan(&orphanedCount)
230
    if err != nil {
231
        span.SetAttributes(attribute.String("error", err.Error()))
232
        return nil, err
233
    }
234
    stats["orphaned_responses"] = orphanedCount
235

236
    span.SetAttributes(
237
        attribute.Int("cleanup.stats.legacy_questions", legacyCount),
238
        attribute.Int("cleanup.stats.orphaned_responses", orphanedCount),
239
    )
240

241
    return stats, nil
242
}
243


			
quizapp internal services worker_service.go
62.2%
Statements
265/426
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "fmt"
7
    "time"
8

9
    "quizapp/internal/api"
10
    "quizapp/internal/models"
11
    "quizapp/internal/observability"
12
    contextutils "quizapp/internal/utils"
13

14
    "go.opentelemetry.io/otel"
15
    "go.opentelemetry.io/otel/attribute"
16
    "go.opentelemetry.io/otel/codes"
17
    "go.opentelemetry.io/otel/trace"
18
)
19

20
// DailyQuestionServiceInterface defines the interface for daily question operations
21
type DailyQuestionServiceInterface interface {
22
    AssignDailyQuestions(ctx context.Context, userID int, date time.Time) error
23
    RegenerateDailyQuestions(ctx context.Context, userID int, date time.Time) error
24
    GetDailyQuestions(ctx context.Context, userID int, date time.Time) ([]*models.DailyQuestionAssignmentWithQuestion, error)
25
    MarkQuestionCompleted(ctx context.Context, userID, questionID int, date time.Time) error
26
    ResetQuestionCompleted(ctx context.Context, userID, questionID int, date time.Time) error
27
    SubmitDailyQuestionAnswer(ctx context.Context, userID, questionID int, date time.Time, userAnswerIndex int) (*api.AnswerResponse, error)
28
    GetAvailableDates(ctx context.Context, userID int) ([]time.Time, error)
29
    GetDailyProgress(ctx context.Context, userID int, date time.Time) (*models.DailyProgress, error)
30
    GetDailyQuestionsCount(ctx context.Context, userID int, date time.Time) (int, error)
31
    GetCompletedDailyQuestionsCount(ctx context.Context, userID int, date time.Time) (int, error)
32
    GetQuestionHistory(ctx context.Context, userID, questionID, days int) ([]*models.DailyQuestionHistory, error)
33
}
34

35
// DailyQuestionService implements daily question assignment and management
36
type DailyQuestionService struct {
37
    db              *sql.DB
38
    logger          *observability.Logger
39
    questionService QuestionServiceInterface
40
    learningService LearningServiceInterface
41
}
42

43
// NewDailyQuestionService creates a new DailyQuestionService instance
44
15x
func NewDailyQuestionService(db *sql.DB, logger *observability.Logger, questionService QuestionServiceInterface, learningService LearningServiceInterface) *DailyQuestionService {
45
15x
    return &DailyQuestionService{
46
15x
        db:              db,
47
15x
        logger:          logger,
48
15x
        questionService: questionService,
49
15x
        learningService: learningService,
50
15x
    }
51
15x
}
52

53
// AssignDailyQuestions assigns 10 random questions to a user for a specific date
54
33x
func (s *DailyQuestionService) AssignDailyQuestions(ctx context.Context, userID int, date time.Time) (err error) {
55
33x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "AssignDailyQuestions",
56
33x
        trace.WithAttributes(
57
33x
            attribute.Int("user.id", userID),
58
33x
            attribute.String("date", date.Format("2006-01-02")),
59
33x
        ),
60
33x
    )
61
33x
    defer func() {
62
33x
        if err != nil {
63
2x
            span.RecordError(err, trace.WithStackTrace(true))
64
2x
            span.SetStatus(codes.Error, err.Error())
65
2x
        }
66
33x
        span.End()
67
    }()
68

69
    // Get user to determine language and level preferences
70
33x
    user, err := s.getUserByID(ctx, userID)
71
33x
    if err != nil {
72
        span.RecordError(err)
73
        return contextutils.WrapError(err, "failed to get user")
74
    }
75

76
33x
    if user == nil {
77
        return contextutils.ErrorWithContextf("user not found: %d", userID)
78
    }
79

80
33x
    language := user.PreferredLanguage.String
81
33x
    level := user.CurrentLevel.String
82
33x

83
33x
    if language == "" || level == "" {
84
1x
        return contextutils.ErrorWithContextf("user missing language or level preferences")
85
1x
    }
86

87
    // Get user's daily goal from learning preferences
88
32x
    prefs, perr := s.learningService.GetUserLearningPreferences(ctx, userID)
89
32x
    if perr != nil {
90
        span.RecordError(perr)
91
        return contextutils.WrapError(perr, "failed to get user learning preferences")
92
    }
93
32x
    goal := 10
94
32x
    if prefs != nil && prefs.DailyGoal > 0 {
95
32x
        goal = prefs.DailyGoal
96
32x
    }
97

98
    // Check existing assignments and only fill missing slots up to the user's goal
99
32x
    existingCount, err := s.GetDailyQuestionsCount(ctx, userID, date)
100
32x
    if err != nil {
101
        span.RecordError(err)
102
        return contextutils.WrapError(err, "failed to check existing assignments")
103
    }
104
32x
    if existingCount >= goal {
105
3x
        s.logger.Info(ctx, "Daily questions already assigned for date", map[string]interface{}{
106
3x
            "user_id": userID,
107
3x
            "date":    date.Format("2006-01-02"),
108
3x
            "count":   existingCount,
109
3x
            "goal":    goal,
110
3x
        })
111
3x
        return nil // Already assigned
112
3x
    }
113

114
    // Request more candidates than strictly needed to allow filtering out already-assigned questions
115
29x
    buffer := 10 // request this many extra candidates beyond the user's goal
116
29x
    reqLimit := goal + buffer
117
29x

118
29x
    // Get adaptive questions using an expanded limit so we can filter and still meet goal
119
29x
    questionsWithStats, err := s.questionService.GetAdaptiveQuestionsForDaily(ctx, userID, language, level, reqLimit)
120
29x
    if err != nil {
121
        span.RecordError(err)
122
        return contextutils.WrapError(err, "failed to get adaptive questions for assignment")
123
    }
124

125
29x
    if len(questionsWithStats) == 0 {
126
1x
        // Gather diagnostics to explain why no questions were available
127
1x
        var candidateIDs []int
128
1x
        candidateCount := 0
129
1x
        totalMatching := 0
130
1x
        if s.questionService != nil {
131
1x
            if candidates, qerr := s.questionService.GetAdaptiveQuestionsForDaily(ctx, userID, language, level, 50); qerr == nil && candidates != nil {
132
1x
                candidateCount = len(candidates)
133
1x
                for i, q := range candidates {
134
                    if i >= 10 {
135
                        break
136
                    }
137
                    if q != nil {
138
                        candidateIDs = append(candidateIDs, q.ID)
139
                    }
140
                }
141
            }
142
1x
            if _, total, terr := s.questionService.GetAllQuestionsPaginated(ctx, 1, 1, "", "", "", language, level, nil); terr == nil {
143
1x
                totalMatching = total
144
1x
            }
145
        }
146

147
1x
        return &NoQuestionsAvailableError{
148
1x
            Language:       language,
149
1x
            Level:          level,
150
1x
            CandidateIDs:   candidateIDs,
151
1x
            CandidateCount: candidateCount,
152
1x
            TotalMatching:  totalMatching,
153
1x
        }
154
    }
155

156
    // Filter out questions that are already assigned for this user/date to
157
    // avoid selecting already-inserted questions and thus underfilling the goal.
158
28x
    assignedIDs := make(map[int]bool)
159
28x
    rows, qerr := s.db.QueryContext(ctx, `SELECT question_id FROM daily_question_assignments WHERE user_id = $1 AND assignment_date = $2`, userID, date)
160
28x
    if qerr == nil {
161
28x
        defer func() {
162
28x
            if closeErr := rows.Close(); closeErr != nil {
163
                s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": closeErr.Error()})
164
            }
165
        }()
166
28x
        for rows.Next() {
167
53x
            var qid int
168
53x
            if err := rows.Scan(&qid); err == nil {
169
53x
                assignedIDs[qid] = true
170
53x
            }
171
        }
172
    }
173

174
    // Convert QuestionWithStats to Question for assignment, skipping already-assigned
175
28x
    var questions []models.Question
176
28x
    for _, qws := range questionsWithStats {
177
564x
        if qws == nil || qws.Question == nil {
178
            continue
179
        }
180
564x
        if assignedIDs[qws.ID] {
181
37x
            // already assigned for this date, skip
182
37x
            continue
183
        }
184
527x
        questions = append(questions, *qws.Question)
185
    }
186

187
    // Only insert up to the number of slots we need to fill
188
28x
    toAssign := goal - existingCount
189
28x
    if toAssign < 0 {
190
        toAssign = 0
191
    }
192
28x
    if len(questions) > toAssign {
193
21x
        questions = questions[:toAssign]
194
21x
    }
195

196
    // Begin transaction
197
28x
    tx, err := s.db.BeginTx(ctx, nil)
198
28x
    if err != nil {
199
        span.RecordError(err)
200
        return contextutils.WrapError(err, "failed to begin transaction")
201
    }
202
28x
    defer func() {
203
28x
        if err != nil {
204
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
205
                s.logger.Error(ctx, "Failed to rollback transaction", rollbackErr, map[string]interface{}{
206
                    "user_id": userID,
207
                    "date":    date.Format("2006-01-02"),
208
                })
209
            }
210
        }
211
    }()
212

213
    // Insert assignments (idempotent via conditional INSERT to avoid duplicate rows)
214
28x
    insertQuery := `
215
28x
        INSERT INTO daily_question_assignments (user_id, question_id, assignment_date, created_at)
216
28x
        SELECT $1, $2, $3, $4
217
28x
        WHERE NOT EXISTS (
218
28x
            SELECT 1 FROM daily_question_assignments WHERE user_id = $1 AND question_id = $2 AND assignment_date = $3
219
28x
        )
220
28x
    `
221
28x

222
28x
    for _, question := range questions {
223
322x
        _, err = tx.ExecContext(ctx, insertQuery, userID, question.ID, date, time.Now())
224
322x
        if err != nil {
225
            span.RecordError(err)
226
            return contextutils.WrapError(err, "failed to insert assignment")
227
        }
228
    }
229

230
    // Commit transaction
231
28x
    err = tx.Commit()
232
28x
    if err != nil {
233
        span.RecordError(err)
234
        return contextutils.WrapError(err, "failed to commit transaction")
235
    }
236

237
28x
    s.logger.Info(ctx, "Daily questions assigned successfully", map[string]interface{}{
238
28x
        "user_id": userID,
239
28x
        "date":    date.Format("2006-01-02"),
240
28x
        "count":   len(questions),
241
28x
    })
242
28x

243
28x
    return nil
244
}
245

246
// RegenerateDailyQuestions clears existing daily question assignments and creates new ones for a user and date
247
3x
func (s *DailyQuestionService) RegenerateDailyQuestions(ctx context.Context, userID int, date time.Time) (err error) {
248
3x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "RegenerateDailyQuestions",
249
3x
        trace.WithAttributes(
250
3x
            attribute.Int("user.id", userID),
251
3x
            attribute.String("date", date.Format("2006-01-02")),
252
3x
        ),
253
3x
    )
254
3x
    defer func() {
255
3x
        if err != nil {
256
            span.RecordError(err, trace.WithStackTrace(true))
257
            span.SetStatus(codes.Error, err.Error())
258
        }
259
3x
        span.End()
260
    }()
261

262
    // Get user to determine language and level preferences
263
3x
    user, err := s.getUserByID(ctx, userID)
264
3x
    if err != nil {
265
        span.RecordError(err)
266
        return contextutils.WrapError(err, "failed to get user")
267
    }
268

269
3x
    if user == nil {
270
        return contextutils.ErrorWithContextf("user not found: %d", userID)
271
    }
272

273
3x
    language := user.PreferredLanguage.String
274
3x
    level := user.CurrentLevel.String
275
3x

276
3x
    if language == "" || level == "" {
277
        return contextutils.ErrorWithContextf("user missing language or level preferences")
278
    }
279

280
    // Get user's daily goal from learning preferences
281
3x
    prefs, perr := s.learningService.GetUserLearningPreferences(ctx, userID)
282
3x
    if perr != nil {
283
        span.RecordError(perr)
284
        return contextutils.WrapError(perr, "failed to get user learning preferences")
285
    }
286
3x
    goal := 10
287
3x
    if prefs != nil && prefs.DailyGoal > 0 {
288
3x
        goal = prefs.DailyGoal
289
3x
    }
290

291
    // Request more candidates than strictly needed to allow filtering out already-assigned questions
292
3x
    buffer := 10 // request this many extra candidates beyond the user's goal
293
3x
    reqLimit := goal + buffer
294
3x

295
3x
    // Get adaptive questions using an expanded limit so we can filter and still meet goal
296
3x
    questionsWithStats, err := s.questionService.GetAdaptiveQuestionsForDaily(ctx, userID, language, level, reqLimit)
297
3x
    if err != nil {
298
        span.RecordError(err)
299
        return contextutils.WrapError(err, "failed to get adaptive questions for assignment")
300
    }
301

302
3x
    if len(questionsWithStats) == 0 {
303
        // Gather diagnostics to explain why no questions were available
304
        var candidateIDs []int
305
        candidateCount := 0
306
        totalMatching := 0
307
        if s.questionService != nil {
308
            if candidates, qerr := s.questionService.GetAdaptiveQuestionsForDaily(ctx, userID, language, level, 50); qerr == nil && candidates != nil {
309
                candidateCount = len(candidates)
310
                for i, q := range candidates {
311
                    if i >= 10 {
312
                        break
313
                    }
314
                    if q != nil {
315
                        candidateIDs = append(candidateIDs, q.ID)
316
                    }
317
                }
318
            }
319
            if _, total, terr := s.questionService.GetAllQuestionsPaginated(ctx, 1, 1, "", "", "", language, level, nil); terr == nil {
320
                totalMatching = total
321
            }
322
        }
323

324
        return &NoQuestionsAvailableError{
325
            Language:       language,
326
            Level:          level,
327
            CandidateIDs:   candidateIDs,
328
            CandidateCount: candidateCount,
329
            TotalMatching:  totalMatching,
330
        }
331
    }
332

333
    // Convert QuestionWithStats to Question for assignment
334
3x
    var questions []models.Question
335
3x
    for _, qws := range questionsWithStats {
336
47x
        questions = append(questions, *qws.Question)
337
47x
    }
338

339
    // Begin transaction
340
3x
    tx, err := s.db.BeginTx(ctx, nil)
341
3x
    if err != nil {
342
        span.RecordError(err)
343
        return contextutils.WrapError(err, "failed to begin transaction")
344
    }
345
3x
    defer func() {
346
3x
        if err != nil {
347
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
348
                s.logger.Error(ctx, "Failed to rollback transaction", rollbackErr, map[string]interface{}{
349
                    "user_id": userID,
350
                    "date":    date.Format("2006-01-02"),
351
                })
352
            }
353
        }
354
    }()
355

356
    // First, delete existing assignments for this user and date
357
3x
    deleteQuery := `DELETE FROM daily_question_assignments WHERE user_id = $1 AND assignment_date = $2`
358
3x
    _, err = tx.ExecContext(ctx, deleteQuery, userID, date)
359
3x
    if err != nil {
360
        span.RecordError(err)
361
        return contextutils.WrapError(err, "failed to delete existing assignments")
362
    }
363

364
    // Insert new assignments
365
3x
    insertQuery := `
366
3x
        INSERT INTO daily_question_assignments (user_id, question_id, assignment_date, created_at)
367
3x
        VALUES ($1, $2, $3, $4)
368
3x
    `
369
3x

370
3x
    stmt, err := tx.PrepareContext(ctx, insertQuery)
371
3x
    if err != nil {
372
        span.RecordError(err)
373
        return contextutils.WrapError(err, "failed to prepare statement")
374
    }
375
3x
    defer func() {
376
3x
        if closeErr := stmt.Close(); closeErr != nil {
377
            s.logger.Error(ctx, "Failed to close statement", closeErr, map[string]interface{}{
378
                "user_id": userID,
379
                "date":    date.Format("2006-01-02"),
380
            })
381
        }
382
    }()
383

384
    // Only assign up to the goal amount
385
3x
    assignedCount := 0
386
3x
    for _, question := range questions {
387
35x
        if assignedCount >= goal {
388
3x
            break
389
        }
390
32x
        _, err = stmt.ExecContext(ctx, userID, question.ID, date, time.Now())
391
32x
        if err != nil {
392
            span.RecordError(err)
393
            return contextutils.WrapError(err, "failed to insert assignment")
394
        }
395
32x
        assignedCount++
396
    }
397

398
    // Commit transaction
399
3x
    err = tx.Commit()
400
3x
    if err != nil {
401
        span.RecordError(err)
402
        return contextutils.WrapError(err, "failed to commit transaction")
403
    }
404

405
3x
    s.logger.Info(ctx, "Daily questions regenerated successfully", map[string]interface{}{
406
3x
        "user_id": userID,
407
3x
        "date":    date.Format("2006-01-02"),
408
3x
        "count":   len(questions),
409
3x
    })
410
3x

411
3x
    return nil
412
}
413

414
// GetDailyQuestions retrieves all daily questions for a user on a specific date
415
17x
func (s *DailyQuestionService) GetDailyQuestions(ctx context.Context, userID int, date time.Time) (result0 []*models.DailyQuestionAssignmentWithQuestion, err error) {
416
17x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetDailyQuestions",
417
17x
        trace.WithAttributes(
418
17x
            attribute.Int("user.id", userID),
419
17x
            attribute.String("date", date.Format("2006-01-02")),
420
17x
        ),
421
17x
    )
422
17x
    defer func() {
423
17x
        if err != nil {
424
            span.RecordError(err, trace.WithStackTrace(true))
425
            span.SetStatus(codes.Error, err.Error())
426
        }
427
17x
        span.End()
428
    }()
429

430
17x
    query := `
431
17x
        SELECT dqa.id, dqa.user_id, dqa.question_id, dqa.assignment_date,
432
17x
               dqa.is_completed, dqa.completed_at, dqa.created_at,
433
17x
               dqa.user_answer_index, dqa.submitted_at,
434
17x
               q.id, q.type, q.language, q.level, q.difficulty_score, q.content,
435
17x
               q.correct_answer, q.explanation, q.created_at, q.status,
436
17x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario,
437
17x
               q.style_modifier, q.difficulty_modifier, q.time_context,
438
17x
               -- Daily shown count per user: how many times this user has seen this question in Daily across all dates
439
17x
               (SELECT COUNT(*) FROM daily_question_assignments dqa_all WHERE dqa_all.question_id = dqa.question_id AND dqa_all.user_id = dqa.user_id) AS daily_shown_count,
440
17x
               -- Per-user correctness stats across all time
441
17x
               COALESCE((SELECT COUNT(*) FROM user_responses ur WHERE ur.user_id = dqa.user_id AND ur.question_id = dqa.question_id), 0) AS user_total_responses,
442
17x
               COALESCE((SELECT COUNT(*) FROM user_responses ur WHERE ur.user_id = dqa.user_id AND ur.question_id = dqa.question_id AND ur.is_correct = TRUE), 0) AS user_correct_count,
443
17x
               COALESCE((SELECT COUNT(*) FROM user_responses ur WHERE ur.user_id = dqa.user_id AND ur.question_id = dqa.question_id AND ur.is_correct = FALSE), 0) AS user_incorrect_count
444
17x
        FROM daily_question_assignments dqa
445
17x
        JOIN questions q ON dqa.question_id = q.id
446
17x
        WHERE dqa.user_id = $1 AND dqa.assignment_date = $2
447
17x
        ORDER BY dqa.created_at ASC
448
17x
    `
449
17x

450
17x
    rows, err := s.db.QueryContext(ctx, query, userID, date)
451
17x
    if err != nil {
452
        span.RecordError(err)
453
        return nil, contextutils.WrapError(err, "failed to query daily questions")
454
    }
455
17x
    defer func() {
456
17x
        if closeErr := rows.Close(); closeErr != nil {
457
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{
458
                "user_id": userID,
459
                "date":    date.Format("2006-01-02"),
460
            })
461
        }
462
    }()
463

464
17x
    var assignments []*models.DailyQuestionAssignmentWithQuestion
465
17x
    for rows.Next() {
466
170x
        var assignment models.DailyQuestionAssignmentWithQuestion
467
170x
        var question models.Question
468
170x
        var contentJSON string
469
170x

470
170x
        err := rows.Scan(
471
170x
            &assignment.ID, &assignment.UserID, &assignment.QuestionID, &assignment.AssignmentDate,
472
170x
            &assignment.IsCompleted, &assignment.CompletedAt, &assignment.CreatedAt,
473
170x
            &assignment.UserAnswerIndex, &assignment.SubmittedAt,
474
170x
            &question.ID, &question.Type, &question.Language, &question.Level, &question.DifficultyScore,
475
170x
            &contentJSON, &question.CorrectAnswer, &question.Explanation, &question.CreatedAt, &question.Status,
476
170x
            &question.TopicCategory, &question.GrammarFocus, &question.VocabularyDomain, &question.Scenario,
477
170x
            &question.StyleModifier, &question.DifficultyModifier, &question.TimeContext,
478
170x
            &assignment.DailyShownCount,
479
170x
            &assignment.UserTotalResponses,
480
170x
            &assignment.UserCorrectCount,
481
170x
            &assignment.UserIncorrectCount,
482
170x
        )
483
170x
        if err != nil {
484
            s.logger.Error(ctx, "Failed to scan daily question assignment", err, map[string]interface{}{
485
                "user_id": userID,
486
                "date":    date.Format("2006-01-02"),
487
            })
488
            continue
489
        }
490

491
        // Unmarshal the JSON content
492
170x
        if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
493
            s.logger.Error(ctx, "Failed to unmarshal question content", err, map[string]interface{}{
494
                "user_id": userID,
495
                "date":    date.Format("2006-01-02"),
496
                "content": contentJSON,
497
            })
498
            continue
499
        }
500

501
170x
        assignment.Question = &question
502
170x
        assignments = append(assignments, &assignment)
503
    }
504

505
17x
    if err = rows.Err(); err != nil {
506
        span.RecordError(err)
507
        return nil, contextutils.WrapError(err, "error iterating over rows")
508
    }
509

510
17x
    return assignments, nil
511
}
512

513
// MarkQuestionCompleted marks a daily question as completed
514
5x
func (s *DailyQuestionService) MarkQuestionCompleted(ctx context.Context, userID, questionID int, date time.Time) (err error) {
515
5x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "MarkQuestionCompleted",
516
5x
        trace.WithAttributes(
517
5x
            attribute.Int("user.id", userID),
518
5x
            attribute.Int("question.id", questionID),
519
5x
            attribute.String("date", date.Format("2006-01-02")),
520
5x
        ),
521
5x
    )
522
5x
    defer func() {
523
5x
        if err != nil {
524
            span.RecordError(err, trace.WithStackTrace(true))
525
            span.SetStatus(codes.Error, err.Error())
526
        }
527
5x
        span.End()
528
    }()
529

530
5x
    query := `
531
5x
        UPDATE daily_question_assignments
532
5x
        SET is_completed = true, completed_at = $1
533
5x
        WHERE user_id = $2 AND question_id = $3 AND assignment_date = $4
534
5x
    `
535
5x

536
5x
    result, err := s.db.ExecContext(ctx, query, time.Now(), userID, questionID, date)
537
5x
    if err != nil {
538
        span.RecordError(err)
539
        return contextutils.WrapError(err, "failed to mark question as completed")
540
    }
541

542
5x
    rowsAffected, err := result.RowsAffected()
543
5x
    if err != nil {
544
        span.RecordError(err)
545
        return contextutils.WrapError(err, "failed to get rows affected")
546
    }
547

548
5x
    if rowsAffected == 0 {
549
        return contextutils.ErrAssignmentNotFound
550
    }
551

552
5x
    s.logger.Info(ctx, "Question marked as completed", map[string]interface{}{
553
5x
        "user_id":     userID,
554
5x
        "question_id": questionID,
555
5x
        "date":        date.Format("2006-01-02"),
556
5x
    })
557
5x

558
5x
    return nil
559
}
560

561
// ResetQuestionCompleted resets a daily question to not completed
562
1x
func (s *DailyQuestionService) ResetQuestionCompleted(ctx context.Context, userID, questionID int, date time.Time) (err error) {
563
1x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "ResetQuestionCompleted",
564
1x
        trace.WithAttributes(
565
1x
            attribute.Int("user.id", userID),
566
1x
            attribute.Int("question.id", questionID),
567
1x
            attribute.String("date", date.Format("2006-01-02")),
568
1x
        ),
569
1x
    )
570
1x
    defer func() {
571
1x
        if err != nil {
572
            span.RecordError(err, trace.WithStackTrace(true))
573
            span.SetStatus(codes.Error, err.Error())
574
        }
575
1x
        span.End()
576
    }()
577

578
1x
    query := `
579
1x
        UPDATE daily_question_assignments
580
1x
        SET is_completed = false, completed_at = NULL, user_answer_index = NULL, submitted_at = NULL
581
1x
        WHERE user_id = $1 AND question_id = $2 AND assignment_date = $3
582
1x
    `
583
1x

584
1x
    result, err := s.db.ExecContext(ctx, query, userID, questionID, date)
585
1x
    if err != nil {
586
        span.RecordError(err)
587
        return contextutils.WrapError(err, "failed to reset question completion")
588
    }
589

590
1x
    rowsAffected, err := result.RowsAffected()
591
1x
    if err != nil {
592
        span.RecordError(err)
593
        return contextutils.WrapError(err, "failed to get rows affected")
594
    }
595

596
1x
    if rowsAffected == 0 {
597
        return contextutils.ErrAssignmentNotFound
598
    }
599

600
1x
    s.logger.Info(ctx, "Question reset to not completed", map[string]interface{}{
601
1x
        "user_id":     userID,
602
1x
        "question_id": questionID,
603
1x
        "date":        date.Format("2006-01-02"),
604
1x
    })
605
1x

606
1x
    return nil
607
}
608

609
// GetAvailableDates retrieves all dates for which a user has daily question assignments
610
1x
func (s *DailyQuestionService) GetAvailableDates(ctx context.Context, userID int) (result0 []time.Time, err error) {
611
1x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetAvailableDates",
612
1x
        trace.WithAttributes(
613
1x
            attribute.Int("user.id", userID),
614
1x
        ),
615
1x
    )
616
1x
    defer func() {
617
1x
        if err != nil {
618
            span.RecordError(err, trace.WithStackTrace(true))
619
            span.SetStatus(codes.Error, err.Error())
620
        }
621
1x
        span.End()
622
    }()
623

624
1x
    query := `
625
1x
        SELECT DISTINCT assignment_date
626
1x
        FROM daily_question_assignments
627
1x
        WHERE user_id = $1
628
1x
        ORDER BY assignment_date DESC
629
1x
    `
630
1x

631
1x
    rows, err := s.db.QueryContext(ctx, query, userID)
632
1x
    if err != nil {
633
        span.RecordError(err)
634
        return nil, contextutils.WrapError(err, "failed to query available dates")
635
    }
636
1x
    defer func() {
637
1x
        if closeErr := rows.Close(); closeErr != nil {
638
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{
639
                "user_id": userID,
640
            })
641
        }
642
    }()
643

644
1x
    var dates []time.Time
645
1x
    for rows.Next() {
646
3x
        var date time.Time
647
3x
        err := rows.Scan(&date)
648
3x
        if err != nil {
649
            s.logger.Error(ctx, "Failed to scan date", err, map[string]interface{}{
650
                "user_id": userID,
651
            })
652
            continue
653
        }
654
3x
        dates = append(dates, date)
655
    }
656

657
1x
    if err = rows.Err(); err != nil {
658
        span.RecordError(err)
659
        return nil, contextutils.WrapError(err, "error iterating over rows")
660
    }
661

662
1x
    return dates, nil
663
}
664

665
// GetDailyProgress retrieves the progress for a specific date
666
2x
func (s *DailyQuestionService) GetDailyProgress(ctx context.Context, userID int, date time.Time) (result0 *models.DailyProgress, err error) {
667
2x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetDailyProgress",
668
2x
        trace.WithAttributes(
669
2x
            attribute.Int("user.id", userID),
670
2x
            attribute.String("date", date.Format("2006-01-02")),
671
2x
        ),
672
2x
    )
673
2x
    defer func() {
674
2x
        if err != nil {
675
            span.RecordError(err, trace.WithStackTrace(true))
676
            span.SetStatus(codes.Error, err.Error())
677
        }
678
2x
        span.End()
679
    }()
680

681
2x
    query := `
682
2x
        SELECT
683
2x
            COUNT(*) as total,
684
2x
            COUNT(CASE WHEN is_completed = true THEN 1 END) as completed
685
2x
        FROM daily_question_assignments
686
2x
        WHERE user_id = $1 AND assignment_date = $2
687
2x
    `
688
2x

689
2x
    var total, completed int
690
2x
    err = s.db.QueryRowContext(ctx, query, userID, date).Scan(&total, &completed)
691
2x
    if err != nil {
692
        return nil, contextutils.WrapError(err, "failed to get daily progress")
693
    }
694

695
2x
    progress := &models.DailyProgress{
696
2x
        Date:      date,
697
2x
        Completed: completed,
698
2x
        Total:     total,
699
2x
    }
700
2x

701
2x
    return progress, nil
702
}
703

704
// GetDailyQuestionsCount retrieves the total number of questions assigned for a date
705
32x
func (s *DailyQuestionService) GetDailyQuestionsCount(ctx context.Context, userID int, date time.Time) (result0 int, err error) {
706
32x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetDailyQuestionsCount",
707
32x
        trace.WithAttributes(
708
32x
            attribute.Int("user.id", userID),
709
32x
            attribute.String("date", date.Format("2006-01-02")),
710
32x
        ),
711
32x
    )
712
32x
    defer func() {
713
32x
        if err != nil {
714
            span.RecordError(err, trace.WithStackTrace(true))
715
            span.SetStatus(codes.Error, err.Error())
716
        }
717
32x
        span.End()
718
    }()
719

720
32x
    query := `
721
32x
        SELECT COUNT(*)
722
32x
        FROM daily_question_assignments
723
32x
        WHERE user_id = $1 AND assignment_date = $2
724
32x
    `
725
32x

726
32x
    var count int
727
32x
    err = s.db.QueryRowContext(ctx, query, userID, date).Scan(&count)
728
32x
    if err != nil {
729
        return 0, contextutils.WrapError(err, "failed to get daily questions count")
730
    }
731

732
32x
    return count, nil
733
}
734

735
// GetCompletedDailyQuestionsCount retrieves the number of completed questions for a date
736
func (s *DailyQuestionService) GetCompletedDailyQuestionsCount(ctx context.Context, userID int, date time.Time) (result0 int, err error) {
737
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetCompletedDailyQuestionsCount",
738
        trace.WithAttributes(
739
            attribute.Int("user.id", userID),
740
            attribute.String("date", date.Format("2006-01-02")),
741
        ),
742
    )
743
    defer func() {
744
        if err != nil {
745
            span.RecordError(err, trace.WithStackTrace(true))
746
            span.SetStatus(codes.Error, err.Error())
747
        }
748
        span.End()
749
    }()
750

751
    query := `
752
        SELECT COUNT(*)
753
        FROM daily_question_assignments
754
        WHERE user_id = $1 AND assignment_date = $2 AND is_completed = true
755
    `
756

757
    var count int
758
    err = s.db.QueryRowContext(ctx, query, userID, date).Scan(&count)
759
    if err != nil {
760
        return 0, contextutils.WrapError(err, "failed to get completed daily questions count")
761
    }
762

763
    return count, nil
764
}
765

766
// GetQuestionHistory retrieves the history of a specific question for a user over a given number of days
767
2x
func (s *DailyQuestionService) GetQuestionHistory(ctx context.Context, userID, questionID, days int) (result0 []*models.DailyQuestionHistory, err error) {
768
2x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "GetQuestionHistory",
769
2x
        trace.WithAttributes(
770
2x
            attribute.Int("user.id", userID),
771
2x
            attribute.Int("question.id", questionID),
772
2x
            attribute.Int("days", days),
773
2x
        ),
774
2x
    )
775
2x
    defer func() {
776
2x
        if err != nil {
777
2x
            span.RecordError(err, trace.WithStackTrace(true))
778
2x
            span.SetStatus(codes.Error, err.Error())
779
2x
        }
780
2x
        span.End()
781
    }()
782

783
2x
    if days <= 0 {
784
2x
        return nil, contextutils.ErrorWithContextf("days must be positive")
785
2x
    }
786

787
    query := `
788
        SELECT dqa.assignment_date, dqa.is_completed, dqa.submitted_at,
789
               ur.is_correct
790
        FROM daily_question_assignments dqa
791
        LEFT JOIN daily_assignment_responses dar ON dar.assignment_id = dqa.id
792
        LEFT JOIN user_responses ur ON ur.id = dar.user_response_id
793
        WHERE dqa.user_id = $1 AND dqa.question_id = $2
794
        AND dqa.assignment_date >= NOW() - INTERVAL '` + fmt.Sprintf("%d days", days) + `'
795
        AND dqa.assignment_date <= CURRENT_DATE + INTERVAL '1 day'
796
        ORDER BY dqa.assignment_date ASC
797
    `
798

799
    rows, err := s.db.QueryContext(ctx, query, userID, questionID)
800
    if err != nil {
801
        span.RecordError(err)
802
        return nil, contextutils.WrapError(err, "failed to query question history")
803
    }
804
    defer func() {
805
        if closeErr := rows.Close(); closeErr != nil {
806
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{
807
                "user_id":     userID,
808
                "question_id": questionID,
809
                "days":        days,
810
            })
811
        }
812
    }()
813

814
    var history []*models.DailyQuestionHistory
815
    for rows.Next() {
816
        var historyEntry models.DailyQuestionHistory
817
        var isCorrect sql.NullBool
818
        err := rows.Scan(
819
            &historyEntry.AssignmentDate,
820
            &historyEntry.IsCompleted,
821
            &historyEntry.SubmittedAt,
822
            &isCorrect,
823
        )
824
        if err != nil {
825
            s.logger.Error(ctx, "Failed to scan question history entry", err, map[string]interface{}{
826
                "user_id":         userID,
827
                "question_id":     questionID,
828
                "assignment_date": historyEntry.AssignmentDate,
829
            })
830
            continue
831
        }
832
        if isCorrect.Valid {
833
            historyEntry.IsCorrect = &isCorrect.Bool
834
        } else {
835
            historyEntry.IsCorrect = nil
836
        }
837
        history = append(history, &historyEntry)
838
    }
839

840
    if err = rows.Err(); err != nil {
841
        span.RecordError(err)
842
        return nil, contextutils.WrapError(err, "error iterating over rows")
843
    }
844

845
    return history, nil
846
}
847

848
// getUserByID is a helper method to get user information
849
36x
func (s *DailyQuestionService) getUserByID(ctx context.Context, userID int) (*models.User, error) {
850
36x
    query := `
851
36x
        SELECT id, username, email, timezone, password_hash, last_active,
852
36x
               preferred_language, current_level, ai_provider, ai_model,
853
36x
               ai_enabled, ai_api_key, created_at, updated_at
854
36x
        FROM users
855
36x
        WHERE id = $1
856
36x
    `
857
36x

858
36x
    var user models.User
859
36x
    err := s.db.QueryRowContext(ctx, query, userID).Scan(
860
36x
        &user.ID, &user.Username, &user.Email, &user.Timezone, &user.PasswordHash,
861
36x
        &user.LastActive, &user.PreferredLanguage, &user.CurrentLevel, &user.AIProvider,
862
36x
        &user.AIModel, &user.AIEnabled, &user.AIAPIKey, &user.CreatedAt, &user.UpdatedAt,
863
36x
    )
864
36x
    if err != nil {
865
        if err == sql.ErrNoRows {
866
            return nil, nil
867
        }
868
        return nil, err
869
    }
870

871
36x
    return &user, nil
872
}
873

874
// SubmitDailyQuestionAnswer submits an answer for a daily question and marks it as completed
875
1x
func (s *DailyQuestionService) SubmitDailyQuestionAnswer(ctx context.Context, userID, questionID int, date time.Time, userAnswerIndex int) (result *api.AnswerResponse, err error) {
876
1x
    ctx, span := otel.Tracer("daily-question-service").Start(ctx, "SubmitDailyQuestionAnswer",
877
1x
        trace.WithAttributes(
878
1x
            attribute.Int("user.id", userID),
879
1x
            attribute.Int("question.id", questionID),
880
1x
            attribute.String("date", date.Format("2006-01-02")),
881
1x
            attribute.Int("user_answer_index", userAnswerIndex),
882
1x
        ),
883
1x
    )
884
1x
    defer func() {
885
1x
        if err != nil {
886
            span.RecordError(err, trace.WithStackTrace(true))
887
            span.SetStatus(codes.Error, err.Error())
888
        }
889
1x
        span.End()
890
    }()
891

892
1x
    s.logger.Info(ctx, "SubmitDailyQuestionAnswer started", map[string]interface{}{
893
1x
        "user_id":           userID,
894
1x
        "question_id":       questionID,
895
1x
        "date":              date.Format("2006-01-02"),
896
1x
        "user_answer_index": userAnswerIndex,
897
1x
    })
898
1x

899
1x
    // Check if the question is already answered
900
1x
    s.logger.Info(ctx, "Checking if question is already answered", map[string]interface{}{
901
1x
        "user_id":     userID,
902
1x
        "question_id": questionID,
903
1x
        "date":        date.Format("2006-01-02"),
904
1x
    })
905
1x

906
1x
    query := `
907
1x
        SELECT id, is_completed, user_answer_index, submitted_at
908
1x
        FROM daily_question_assignments
909
1x
        WHERE user_id = $1 AND question_id = $2 AND assignment_date = $3
910
1x
    `
911
1x

912
1x
    var assignmentID int
913
1x
    var isCompleted bool
914
1x
    var existingUserAnswerIndex *int
915
1x
    var existingSubmittedAt *time.Time
916
1x

917
1x
    err = s.db.QueryRowContext(ctx, query, userID, questionID, date).Scan(
918
1x
        &assignmentID, &isCompleted, &existingUserAnswerIndex, &existingSubmittedAt,
919
1x
    )
920
1x
    if err != nil {
921
        if err == sql.ErrNoRows {
922
            return nil, contextutils.ErrAssignmentNotFound
923
        }
924
        return nil, contextutils.WrapError(err, "failed to check question assignment")
925
    }
926

927
    // Check if already answered
928
1x
    if isCompleted && existingUserAnswerIndex != nil && existingSubmittedAt != nil {
929
        return nil, contextutils.ErrQuestionAlreadyAnswered
930
    }
931

932
    // Get the question details to validate answer and get correct answer
933
1x
    question, err := s.questionService.GetQuestionByID(ctx, questionID)
934
1x
    if err != nil {
935
        return nil, contextutils.WrapError(err, "failed to get question details")
936
    }
937

938
1x
    if question == nil {
939
        return nil, contextutils.ErrQuestionNotFound
940
    }
941

942
    // Extract options from content map
943
1x
    contentMap := question.Content
944
1x
    s.logger.Info(ctx, "Question content debug", map[string]interface{}{
945
1x
        "question_id": questionID,
946
1x
        "content_map": contentMap,
947
1x
    })
948
1x

949
1x
    optionsInterface, ok := contentMap["options"]
950
1x
    if !ok {
951
        s.logger.Error(ctx, "Question content missing options", nil, map[string]interface{}{
952
            "question_id": questionID,
953
            "content_map": contentMap,
954
        })
955
        return nil, contextutils.ErrorWithContextf("question content missing options")
956
    }
957

958
1x
    options, ok := optionsInterface.([]interface{})
959
1x
    if !ok {
960
        s.logger.Error(ctx, "Invalid options format", nil, map[string]interface{}{
961
            "question_id":       questionID,
962
            "options_interface": optionsInterface,
963
            "options_type":      fmt.Sprintf("%T", optionsInterface),
964
        })
965
        return nil, contextutils.ErrorWithContextf("invalid options format")
966
    }
967

968
    // Validate user answer index
969
1x
    if userAnswerIndex < 0 || userAnswerIndex >= len(options) {
970
        return nil, contextutils.ErrInvalidAnswerIndex
971
    }
972

973
    // Check if answer is correct
974
1x
    isCorrect := question.CorrectAnswer == userAnswerIndex
975
1x

976
1x
    // Begin transaction
977
1x
    tx, err := s.db.BeginTx(ctx, nil)
978
1x
    if err != nil {
979
        return nil, contextutils.WrapError(err, "failed to begin transaction")
980
    }
981

982
1x
    defer func() {
983
1x
        if err != nil {
984
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
985
                s.logger.Error(ctx, "Failed to rollback transaction", rollbackErr, map[string]interface{}{
986
                    "error": rollbackErr.Error(),
987
                })
988
            }
989
        }
990
    }()
991

992
    // Update the assignment with the user's answer and mark as completed
993
1x
    updateQuery := `
994
1x
        UPDATE daily_question_assignments
995
1x
        SET is_completed = true, completed_at = NOW(), user_answer_index = $1, submitted_at = NOW()
996
1x
        WHERE id = $2
997
1x
    `
998
1x

999
1x
    _, err = tx.ExecContext(ctx, updateQuery, userAnswerIndex, assignmentID)
1000
1x
    if err != nil {
1001
        return nil, contextutils.WrapError(err, "failed to update assignment")
1002
    }
1003

1004
    // Commit transaction
1005
1x
    err = tx.Commit()
1006
1x
    if err != nil {
1007
        return nil, contextutils.WrapError(err, "failed to commit transaction")
1008
    }
1009

1010
    // Record canonical user response via learningService so history queries see is_correct
1011
    // Use RecordAnswerWithPriorityReturningID to obtain user_responses.id so we can link it to the assignment.
1012
1x
    if s.learningService != nil {
1013
1x
        // record synchronously so we have the response id for mapping
1014
1x
        respID, recErr := s.learningService.RecordAnswerWithPriorityReturningID(ctx, userID, questionID, userAnswerIndex, isCorrect, 0)
1015
1x
        if recErr != nil {
1016
            s.logger.Error(ctx, "Failed to record user response for daily answer", recErr, map[string]interface{}{
1017
                "user_id":           userID,
1018
                "question_id":       questionID,
1019
                "user_answer_index": userAnswerIndex,
1020
            })
1021
        } else {
1022
1x
            // Insert mapping to daily_assignment_responses synchronously so tests that run immediately can observe it
1023
1x
            _, mapErr := s.db.ExecContext(ctx, `
1024
1x
                INSERT INTO daily_assignment_responses (assignment_id, user_response_id, created_at)
1025
1x
                VALUES ($1, $2, NOW())
1026
1x
                ON CONFLICT (assignment_id) DO UPDATE SET user_response_id = EXCLUDED.user_response_id, created_at = EXCLUDED.created_at
1027
1x
            `, assignmentID, respID)
1028
1x
            if mapErr != nil {
1029
                // Log but don't fail user's request
1030
                s.logger.Error(ctx, "Failed to insert daily_assignment_responses mapping", mapErr, map[string]interface{}{
1031
                    "assignment_id":    assignmentID,
1032
                    "user_response_id": respID,
1033
                })
1034
            }
1035

1036
            // If the answer was correct, remove future assignments for this question within the avoid window
1037
1x
            if isCorrect {
1038
1x
                // Determine avoidDays via questionService if possible; default to 7
1039
1x
                avoidDays := 7
1040
1x
                switch qs := s.questionService.(type) {
1041
1x
                case interface{ getDailyRepeatAvoidDays() int }:
1042
1x
                    avoidDays = qs.getDailyRepeatAvoidDays()
1043
                default:
1044
                    // leave default
1045
                }
1046

1047
1x
                startDate := date.AddDate(0, 0, 1)
1048
1x
                endDate := date.AddDate(0, 0, avoidDays)
1049
1x

1050
1x
                deleteQuery := `DELETE FROM daily_question_assignments WHERE user_id = $1 AND question_id = $2 AND assignment_date >= $3 AND assignment_date <= $4`
1051
1x
                if _, delErr := s.db.ExecContext(ctx, deleteQuery, userID, questionID, startDate, endDate); delErr != nil {
1052
                    s.logger.Error(ctx, "Failed to delete future daily assignments", delErr, map[string]interface{}{
1053
                        "user_id":     userID,
1054
                        "question_id": questionID,
1055
                        "start":       startDate,
1056
                        "end":         endDate,
1057
                    })
1058
                } else {
1059
1x
                    // Future assignments removed successfully; worker will top up missing slots on its next run
1060
1x
                    s.logger.Info(ctx, "Deleted future daily assignments for question; worker will refill dates as needed", map[string]interface{}{
1061
1x
                        "user_id":     userID,
1062
1x
                        "question_id": questionID,
1063
1x
                        "start":       startDate,
1064
1x
                        "end":         endDate,
1065
1x
                    })
1066
1x
                }
1067
            }
1068
        }
1069
    }
1070

1071
    // Build response
1072
1x
    userAnswer := options[userAnswerIndex].(string)
1073
1x
    response := &api.AnswerResponse{
1074
1x
        UserAnswerIndex: &userAnswerIndex,
1075
1x
        UserAnswer:      &userAnswer,
1076
1x
        IsCorrect:       &isCorrect,
1077
1x
    }
1078
1x

1079
1x
    // Add correct answer and explanation if available
1080
1x
    response.CorrectAnswerIndex = &question.CorrectAnswer
1081
1x
    if question.Explanation != "" {
1082
1x
        response.Explanation = &question.Explanation
1083
1x
    }
1084

1085
1x
    s.logger.Info(ctx, "Daily question answer submitted", map[string]interface{}{
1086
1x
        "user_id":           userID,
1087
1x
        "question_id":       questionID,
1088
1x
        "date":              date.Format("2006-01-02"),
1089
1x
        "user_answer_index": userAnswerIndex,
1090
1x
        "is_correct":        isCorrect,
1091
1x
    })
1092
1x

1093
1x
    return response, nil
1094
}
1095


			
quizapp internal services worker_service.go
90.9%
Statements
10/11
1
// Package services provides business logic services for the quiz application.
2
package services
3

4
import (
5
    "context"
6
    "database/sql"
7

8
    "quizapp/internal/config"
9
    "quizapp/internal/observability"
10
    "quizapp/internal/services/mailer"
11
)
12

13
// CreateEmailService creates an appropriate email service based on configuration
14
// If the application is running in test mode, it returns a TestEmailService
15
// Otherwise, it returns the regular EmailService
16
2x
func CreateEmailService(cfg *config.Config, logger *observability.Logger) mailer.Mailer {
17
2x
    if cfg.IsTest {
18
1x
        logger.Info(context.Background(), "Using test email service", map[string]interface{}{
19
1x
            "test_mode": true,
20
1x
        })
21
1x
        return NewTestEmailService(cfg, logger)
22
1x
    }
23

24
1x
    return NewEmailService(cfg, logger)
25
}
26

27
// CreateEmailServiceWithDB creates an appropriate email service with database connection based on configuration
28
// If the application is running in test mode, it returns a TestEmailService
29
// Otherwise, it returns the regular EmailService
30
2x
func CreateEmailServiceWithDB(cfg *config.Config, logger *observability.Logger, db *sql.DB) mailer.Mailer {
31
2x
    if cfg.IsTest {
32
1x
        logger.Info(context.Background(), "Using test email service with DB", map[string]interface{}{
33
1x
            "test_mode": true,
34
1x
        })
35
1x
        return NewTestEmailServiceWithDB(cfg, logger, db)
36
1x
    }
37

38
1x
    if db == nil {
39
1x
        logger.Error(context.Background(), "Database connection is nil, cannot create EmailService", nil, map[string]interface{}{
40
1x
            "error": "nil_database_connection",
41
1x
        })
42
1x
        panic("EmailService requires a non-nil database connection")
43
    }
44

45
    return NewEmailServiceWithDB(cfg, logger, db)
46
}
47


			
quizapp internal services worker_service.go
42.7%
Statements
35/82
1
// Package services provides business logic services for the quiz application.
2
package services
3

4
import (
5
    "context"
6
    "database/sql"
7
    "fmt"
8
    "html/template"
9
    "strings"
10
    "time"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/models"
14
    "quizapp/internal/observability"
15
    serviceinterfaces "quizapp/internal/services/interfaces"
16
    contextutils "quizapp/internal/utils"
17

18
    "go.opentelemetry.io/otel"
19
    "go.opentelemetry.io/otel/attribute"
20
    "go.opentelemetry.io/otel/trace"
21
    "gopkg.in/mail.v2"
22
)
23

24
// EmailService implements the interfaces.EmailService interface using gomail
25
type EmailService struct {
26
    cfg    *config.Config
27
    logger *observability.Logger
28
    dialer *mail.Dialer
29
    db     *sql.DB
30
}
31

32
// EmailServiceInterface defines the interface for email functionality
33
type EmailServiceInterface = serviceinterfaces.EmailService
34

35
// Ensure EmailService implements the EmailServiceInterface
36
var _ serviceinterfaces.EmailService = (*EmailService)(nil)
37

38
// NewEmailService creates a new EmailService instance
39
14x
func NewEmailService(cfg *config.Config, logger *observability.Logger) *EmailService {
40
14x
    var dialer *mail.Dialer
41
14x
    if cfg.Email.Enabled && cfg.Email.SMTP.Host != "" {
42
7x
        dialer = mail.NewDialer(
43
7x
            cfg.Email.SMTP.Host,
44
7x
            cfg.Email.SMTP.Port,
45
7x
            cfg.Email.SMTP.Username,
46
7x
            cfg.Email.SMTP.Password,
47
7x
        )
48
7x
    }
49

50
14x
    return &EmailService{
51
14x
        cfg:    cfg,
52
14x
        logger: logger,
53
14x
        dialer: dialer,
54
14x
    }
55
}
56

57
// NewEmailServiceWithDB creates a new EmailService instance with database connection
58
1x
func NewEmailServiceWithDB(cfg *config.Config, logger *observability.Logger, db *sql.DB) *EmailService {
59
1x
    if db == nil {
60
1x
        panic("EmailService requires a non-nil database connection")
61
    }
62

63
    var dialer *mail.Dialer
64
    if cfg.Email.Enabled && cfg.Email.SMTP.Host != "" {
65
        dialer = mail.NewDialer(
66
            cfg.Email.SMTP.Host,
67
            cfg.Email.SMTP.Port,
68
            cfg.Email.SMTP.Username,
69
            cfg.Email.SMTP.Password,
70
        )
71
    }
72

73
    return &EmailService{
74
        cfg:    cfg,
75
        logger: logger,
76
        dialer: dialer,
77
        db:     db,
78
    }
79
}
80

81
// SendDailyReminder sends a daily reminder email to a user
82
2x
func (e *EmailService) SendDailyReminder(ctx context.Context, user *models.User) (err error) {
83
2x
    ctx, span := otel.Tracer("email-service").Start(ctx, "SendDailyReminder",
84
2x
        trace.WithAttributes(
85
2x
            attribute.Int("user.id", user.ID),
86
2x
            attribute.String("user.email", user.Email.String),
87
2x
        ),
88
2x
    )
89
2x
    defer observability.FinishSpan(span, &err)
90
2x

91
2x
    if !e.IsEnabled() {
92
1x
        e.logger.Info(ctx, "Email disabled, skipping daily reminder", map[string]interface{}{
93
1x
            "user_id": user.ID,
94
1x
            "email":   user.Email.String,
95
1x
        })
96
1x
        return nil
97
1x
    }
98

99
1x
    if !user.Email.Valid || user.Email.String == "" {
100
1x
        e.logger.Warn(ctx, "User has no email address, skipping daily reminder", map[string]interface{}{
101
1x
            "user_id": user.ID,
102
1x
        })
103
1x
        return nil
104
1x
    }
105

106
    // Determine daily goal from DB
107
    dailyGoal := 10
108
    var dg sql.NullInt64
109
    if err := e.db.QueryRowContext(ctx, "SELECT daily_goal FROM user_learning_preferences WHERE user_id = $1", user.ID).Scan(&dg); err == nil && dg.Valid {
110
        dailyGoal = int(dg.Int64)
111
    }
112

113
    // Generate email data
114
    data := map[string]interface{}{
115
        "Username":       user.Username,
116
        "QuizAppURL":     e.cfg.Server.AppBaseURL, // Frontend app URL for email links
117
        "CurrentDate":    time.Now().Format("January 2, 2006"),
118
        "DailyGoal":      dailyGoal,
119
        "UnsubscribeURL": fmt.Sprintf("%s/settings", e.cfg.Server.AppBaseURL),
120
    }
121

122
    subject := "Time for your daily quiz! ð"
123

124
    err = e.SendEmail(ctx, user.Email.String, subject, "daily_reminder", data)
125
    if err != nil {
126
        return contextutils.WrapError(err, "failed to send daily reminder")
127
    }
128

129
    e.logger.Info(ctx, "Daily reminder sent successfully", map[string]interface{}{
130
        "user_id": user.ID,
131
        "email":   user.Email.String,
132
    })
133

134
    return nil
135
}
136

137
// SendEmail sends a generic email with the given parameters
138
2x
func (e *EmailService) SendEmail(ctx context.Context, to, subject, templateName string, data map[string]interface{}) (err error) {
139
2x
    ctx, span := otel.Tracer("email-service").Start(ctx, "SendEmail",
140
2x
        trace.WithAttributes(
141
2x
            attribute.String("email.to", to),
142
2x
            attribute.String("email.subject", subject),
143
2x
            attribute.String("email.template", templateName),
144
2x
        ),
145
2x
    )
146
2x
    defer observability.FinishSpan(span, &err)
147
2x

148
2x
    if !e.IsEnabled() {
149
2x
        e.logger.Info(ctx, "Email disabled, skipping email send", map[string]interface{}{
150
2x
            "to":       to,
151
2x
            "template": templateName,
152
2x
        })
153
2x
        return nil
154
2x
    }
155

156
    if e.dialer == nil {
157
        return contextutils.ErrorWithContextf("email service not properly configured")
158
    }
159

160
    // Create email message
161
    m := mail.NewMessage()
162
    m.SetHeader("From", fmt.Sprintf("%s <%s>", e.cfg.Email.SMTP.FromName, e.cfg.Email.SMTP.FromAddress))
163
    m.SetHeader("To", to)
164
    m.SetHeader("Subject", subject)
165

166
    // Generate email content from template
167
    content, err := e.generateEmailContent(templateName, data)
168
    if err != nil {
169
        return contextutils.WrapError(err, "failed to generate email content")
170
    }
171

172
    m.SetBody("text/html", content)
173

174
    // Send email
175
    if err = e.dialer.DialAndSend(m); err != nil {
176
        e.logger.Error(ctx, "Failed to send email", err, map[string]interface{}{
177
            "to":       to,
178
            "template": templateName,
179
            "subject":  subject,
180
        })
181
        return contextutils.WrapError(err, "failed to send email")
182
    }
183

184
    e.logger.Info(ctx, "Email sent successfully", map[string]interface{}{
185
        "to":       to,
186
        "template": templateName,
187
        "subject":  subject,
188
    })
189

190
    return nil
191
}
192

193
// RecordSentNotification records a sent notification in the database
194
func (e *EmailService) RecordSentNotification(ctx context.Context, userID int, notificationType, subject, templateName, status, errorMessage string) (err error) {
195
    ctx, span := otel.Tracer("email-service").Start(ctx, "RecordSentNotification",
196
        trace.WithAttributes(
197
            attribute.Int("user.id", userID),
198
            attribute.String("notification.type", notificationType),
199
            attribute.String("notification.status", status),
200
        ),
201
    )
202
    defer observability.FinishSpan(span, &err)
203

204
    if e.db == nil {
205
        e.logger.Error(ctx, "Database connection is nil, cannot record notification", nil, map[string]interface{}{
206
            "user_id":           userID,
207
            "notification_type": notificationType,
208
        })
209
        return contextutils.ErrorWithContextf("EmailService database connection is nil")
210
    }
211

212
    query := `
213
        INSERT INTO sent_notifications (user_id, notification_type, subject, template_name, sent_at, status, error_message)
214
        VALUES ($1, $2, $3, $4, $5, $6, $7)
215
    `
216

217
    _, err = e.db.ExecContext(ctx, query, userID, notificationType, subject, templateName, time.Now(), status, errorMessage)
218
    if err != nil {
219
        e.logger.Error(ctx, "Failed to record sent notification", err, map[string]interface{}{
220
            "user_id":           userID,
221
            "notification_type": notificationType,
222
            "status":            status,
223
        })
224
        return contextutils.WrapError(err, "failed to record sent notification")
225
    }
226

227
    e.logger.Info(ctx, "Recorded sent notification", map[string]interface{}{
228
        "user_id":           userID,
229
        "notification_type": notificationType,
230
        "status":            status,
231
    })
232

233
    return nil
234
}
235

236
// IsEnabled returns whether email functionality is enabled
237
9x
func (e *EmailService) IsEnabled() bool {
238
9x
    return e.cfg.Email.Enabled && e.cfg.Email.SMTP.Host != ""
239
9x
}
240

241
// generateEmailContent generates email content from templates
242
2x
func (e *EmailService) generateEmailContent(templateName string, data map[string]interface{}) (string, error) {
243
2x
    // For now, we'll use a simple template system
244
2x
    // In a real implementation, you might load templates from files or database
245
2x
    switch templateName {
246
1x
    case "daily_reminder":
247
1x
        return e.generateDailyReminderTemplate(data)
248
    case "test_email":
249
        return e.generateTestEmailTemplate(data)
250
1x
    default:
251
1x
        return "", contextutils.ErrorWithContextf("unknown template: %s", templateName)
252
    }
253
}
254

255
// generateDailyReminderTemplate generates the daily reminder email template
256
2x
func (e *EmailService) generateDailyReminderTemplate(data map[string]interface{}) (string, error) {
257
2x
    const templateStr = `
258
2x
<!DOCTYPE html>
259
2x
<html>
260
2x
<head>
261
2x
    <meta charset="UTF-8">
262
2x
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
263
2x
    <title>Daily Quiz Reminder</title>
264
2x
    <style>
265
2x
        body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
266
2x
        .container { max-width: 600px; margin: 0 auto; padding: 20px; }
267
2x
        .header { background-color: #4CAF50; color: white; padding: 20px; text-align: center; border-radius: 5px 5px 0 0; }
268
2x
        .content { background-color: #f9f9f9; padding: 20px; }
269
2x
        .button { display: inline-block; background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; margin: 20px 0; }
270
2x
        .footer { background-color: #eee; padding: 15px; text-align: center; font-size: 12px; color: #666; border-radius: 0 0 5px 5px; }
271
2x
    </style>
272
2x
</head>
273
2x
<body>
274
2x
    <div class="container">
275
2x
        <div class="header">
276
2x
            <h1>ð Daily Quiz Reminder</h1>
277
2x
        </div>
278
2x
        <div class="content">
279
2x
            <h2>Hello {{.Username}}!</h2>
280
2x
            <p>It's {{.CurrentDate}} and time for your daily questions!</p>
281
2x
            <p>Your goal today: <strong>{{.DailyGoal}} questions</strong></p>
282
2x
            <p>Keep up the great work and continue improving your language skills!</p>
283
2x
            <div style="text-align: center;">
284
2x
                <a href="{{.QuizAppURL}}/daily" class="button">Start Your Daily Questions</a>
285
2x
            </div>
286
2x
        </div>
287
2x
        <div class="footer">
288
2x
            <p>This email was sent by Quiz App. If you no longer wish to receive these reminders, you can <a href="{{.UnsubscribeURL}}">unsubscribe here</a>.</p>
289
2x
        </div>
290
2x
    </div>
291
2x
</body>
292
2x
</html>`
293
2x

294
2x
    tmpl, err := template.New("daily_reminder").Parse(templateStr)
295
2x
    if err != nil {
296
        return "", contextutils.WrapError(err, "failed to parse template")
297
    }
298

299
2x
    var buf strings.Builder
300
2x
    if err := tmpl.Execute(&buf, data); err != nil {
301
        return "", contextutils.WrapError(err, "failed to execute template")
302
    }
303

304
2x
    return buf.String(), nil
305
}
306

307
// generateTestEmailTemplate generates the test email template
308
2x
func (e *EmailService) generateTestEmailTemplate(data map[string]interface{}) (string, error) {
309
2x
    const templateStr = `
310
2x
<!DOCTYPE html>
311
2x
<html>
312
2x
<head>
313
2x
    <meta charset="UTF-8">
314
2x
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
315
2x
    <title>Test Email</title>
316
2x
    <style>
317
2x
        body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
318
2x
        .container { max-width: 600px; margin: 0 auto; padding: 20px; }
319
2x
        .header { background-color: #2196F3; color: white; padding: 20px; text-align: center; border-radius: 5px 5px 0 0; }
320
2x
        .content { background-color: #f9f9f9; padding: 20px; }
321
2x
        .footer { background-color: #eee; padding: 15px; text-align: center; font-size: 12px; color: #666; border-radius: 0 0 5px 5px; }
322
2x
    </style>
323
2x
</head>
324
2x
<body>
325
2x
    <div class="container">
326
2x
        <div class="header">
327
2x
            <h1>ð Test Email</h1>
328
2x
        </div>
329
2x
        <div class="content">
330
2x
            <h2>Hello {{.Username}}!</h2>
331
2x
            <p>This is a test email to verify that your email settings are working correctly.</p>
332
2x
            <p><strong>Test Time:</strong> {{.TestTime}}</p>
333
2x
            <p><strong>Message:</strong> {{.Message}}</p>
334
2x
            <p>If you received this email, your email configuration is working properly!</p>
335
2x
        </div>
336
2x
        <div class="footer">
337
2x
            <p>This is a test email from Quiz App. No action is required.</p>
338
2x
        </div>
339
2x
    </div>
340
2x
</body>
341
2x
</html>
342
2x
`
343
2x

344
2x
    tmpl, err := template.New("test_email").Parse(templateStr)
345
2x
    if err != nil {
346
        return "", contextutils.WrapError(err, "failed to parse template")
347
    }
348

349
2x
    var buf strings.Builder
350
2x
    if err := tmpl.Execute(&buf, data); err != nil {
351
        return "", contextutils.WrapError(err, "failed to execute template")
352
    }
353

354
2x
    return buf.String(), nil
355
}
356


			
quizapp internal services worker_service.go
83.3%
Statements
25/30
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "time"
7

8
    "quizapp/internal/models"
9
    "quizapp/internal/observability"
10
    contextutils "quizapp/internal/utils"
11
)
12

13
// GenerationHint represents an active generation hint
14
type GenerationHint struct {
15
    ID             int       `db:"id"`
16
    UserID         int       `db:"user_id"`
17
    Language       string    `db:"language"`
18
    Level          string    `db:"level"`
19
    QuestionType   string    `db:"question_type"`
20
    PriorityWeight int       `db:"priority_weight"`
21
    ExpiresAt      time.Time `db:"expires_at"`
22
    CreatedAt      time.Time `db:"created_at"`
23
}
24

25
// GenerationHintServiceInterface defines the API for managing generation hints
26
type GenerationHintServiceInterface interface {
27
    UpsertHint(ctx context.Context, userID int, language, level string, qType models.QuestionType, ttl time.Duration) error
28
    GetActiveHintsForUser(ctx context.Context, userID int) ([]GenerationHint, error)
29
    ClearHint(ctx context.Context, userID int, language, level string, qType models.QuestionType) error
30
}
31

32
// GenerationHintService implements hint management
33
type GenerationHintService struct {
34
    db     *sql.DB
35
    logger *observability.Logger
36
}
37

38
// NewGenerationHintService constructs a service for managing short-lived per-user
39
// generation hints that nudge the worker to prioritize specific question types
40
// (e.g., reading comprehension) when the user is waiting for generation.
41
1x
func NewGenerationHintService(db *sql.DB, logger *observability.Logger) *GenerationHintService {
42
1x
    return &GenerationHintService{db: db, logger: logger}
43
1x
}
44

45
// UpsertHint creates or refreshes a hint with the given TTL
46
1x
func (s *GenerationHintService) UpsertHint(ctx context.Context, userID int, language, level string, qType models.QuestionType, ttl time.Duration) (err error) {
47
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "upsert_generation_hint")
48
1x
    defer observability.FinishSpan(span, &err)
49
1x

50
1x
    expiresAt := time.Now().Add(ttl)
51
1x
    _, err = s.db.ExecContext(ctx, `
52
1x
        INSERT INTO generation_hints (user_id, language, level, question_type, priority_weight, expires_at)
53
1x
        VALUES ($1, $2, $3, $4, 1, $5)
54
1x
        ON CONFLICT (user_id, language, level, question_type) DO UPDATE SET
55
1x
            priority_weight = generation_hints.priority_weight + 1,
56
1x
            expires_at = EXCLUDED.expires_at,
57
1x
            created_at = generation_hints.created_at
58
1x
    `, userID, language, level, string(qType), expiresAt)
59
1x
    if err != nil {
60
        return contextutils.WrapError(err, "failed to upsert generation hint")
61
    }
62
1x
    return nil
63
}
64

65
// GetActiveHintsForUser returns non-expired hints for the user
66
2x
func (s *GenerationHintService) GetActiveHintsForUser(ctx context.Context, userID int) (result0 []GenerationHint, err error) {
67
2x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_active_generation_hints")
68
2x
    defer observability.FinishSpan(span, &err)
69
2x

70
2x
    rows, err := s.db.QueryContext(ctx, `
71
2x
        SELECT id, user_id, language, level, question_type, priority_weight, expires_at, created_at
72
2x
        FROM generation_hints
73
2x
        WHERE user_id = $1 AND expires_at > NOW()
74
2x
        ORDER BY created_at ASC
75
2x
    `, userID)
76
2x
    if err != nil {
77
        return nil, contextutils.WrapError(err, "failed to query generation hints")
78
    }
79
2x
    defer func() { _ = rows.Close() }()
80

81
2x
    var hints []GenerationHint
82
2x
    for rows.Next() {
83
1x
        var h GenerationHint
84
1x
        if err := rows.Scan(&h.ID, &h.UserID, &h.Language, &h.Level, &h.QuestionType, &h.PriorityWeight, &h.ExpiresAt, &h.CreatedAt); err != nil {
85
            return nil, contextutils.WrapError(err, "failed to scan generation hint")
86
        }
87
1x
        hints = append(hints, h)
88
    }
89
2x
    if err := rows.Err(); err != nil {
90
        return nil, contextutils.WrapError(err, "error iterating generation hints")
91
    }
92
2x
    return hints, nil
93
}
94

95
// ClearHint deletes a specific hint
96
1x
func (s *GenerationHintService) ClearHint(ctx context.Context, userID int, language, level string, qType models.QuestionType) (err error) {
97
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "clear_generation_hint")
98
1x
    defer observability.FinishSpan(span, &err)
99
1x

100
1x
    _, err = s.db.ExecContext(ctx, `
101
1x
        DELETE FROM generation_hints
102
1x
        WHERE user_id = $1 AND language = $2 AND level = $3 AND question_type = $4
103
1x
    `, userID, language, level, string(qType))
104
1x
    if err != nil {
105
        return contextutils.WrapError(err, "failed to clear generation hint")
106
    }
107
1x
    return nil
108
}
109


			
quizapp internal services worker_service.go
76.2%
Statements
581/762
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "fmt"
7
    "math"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/config"
12
    "quizapp/internal/models"
13
    "quizapp/internal/observability"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/lib/pq"
17
    "go.opentelemetry.io/otel/attribute"
18
    "go.opentelemetry.io/otel/codes"
19
    "go.opentelemetry.io/otel/trace"
20
)
21

22
// LearningServiceInterface defines the interface for the learning service
23
type LearningServiceInterface interface {
24
    RecordUserResponse(ctx context.Context, response *models.UserResponse) error
25
    GetUserProgress(ctx context.Context, userID int) (*models.UserProgress, error)
26
    GetWeakestTopics(ctx context.Context, userID, limit int) ([]*models.PerformanceMetrics, error)
27
    ShouldAvoidQuestion(ctx context.Context, userID, questionID int) (bool, error)
28
    GetUserQuestionStats(ctx context.Context, userID int) (*UserQuestionStats, error)
29
    // Priority system methods
30
    RecordAnswerWithPriority(ctx context.Context, userID, questionID, answerIndex int, isCorrect bool, responseTime int) error
31
    // RecordAnswerWithPriorityReturningID records the response and returns the created user_responses.id
32
    RecordAnswerWithPriorityReturningID(ctx context.Context, userID, questionID, answerIndex int, isCorrect bool, responseTime int) (int, error)
33
    MarkQuestionAsKnown(ctx context.Context, userID, questionID int, confidenceLevel *int) error
34
    GetUserLearningPreferences(ctx context.Context, userID int) (*models.UserLearningPreferences, error)
35
    UpdateLastDailyReminderSent(ctx context.Context, userID int) error
36
    CalculatePriorityScore(ctx context.Context, userID, questionID int) (float64, error)
37
    UpdateUserLearningPreferences(ctx context.Context, userID int, prefs *models.UserLearningPreferences) (*models.UserLearningPreferences, error)
38
    GetUserQuestionConfidenceLevel(ctx context.Context, userID, questionID int) (*int, error)
39
    // Analytics methods
40
    GetPriorityScoreDistribution(ctx context.Context) (map[string]interface{}, error)
41
    GetHighPriorityQuestions(ctx context.Context, limit int) ([]map[string]interface{}, error)
42
    GetWeakAreasByTopic(ctx context.Context, limit int) ([]map[string]interface{}, error)
43
    GetLearningPreferencesUsage(ctx context.Context) (map[string]interface{}, error)
44
    GetQuestionTypeGaps(ctx context.Context) ([]map[string]interface{}, error)
45
    GetGenerationSuggestions(ctx context.Context) ([]map[string]interface{}, error)
46
    GetPrioritySystemPerformance(ctx context.Context) (map[string]interface{}, error)
47
    GetBackgroundJobsStatus(ctx context.Context) (map[string]interface{}, error)
48
    // User-specific analytics methods
49
    GetUserPriorityScoreDistribution(ctx context.Context, userID int) (map[string]interface{}, error)
50
    GetUserHighPriorityQuestions(ctx context.Context, userID, limit int) ([]map[string]interface{}, error)
51
    GetUserWeakAreas(ctx context.Context, userID, limit int) ([]map[string]interface{}, error)
52
    // Additional analytics methods for progress API
53
    GetHighPriorityTopics(ctx context.Context, userID int) ([]string, error)
54
    GetGapAnalysis(ctx context.Context, userID int) (map[string]interface{}, error)
55
    GetPriorityDistribution(ctx context.Context, userID int) (map[string]int, error)
56
}
57

58
// UserQuestionStats represents per-user question statistics
59
type UserQuestionStats struct {
60
    UserID           int                `json:"user_id"`
61
    TotalAnswered    int                `json:"total_answered"`
62
    CorrectAnswers   int                `json:"correct_answers"`
63
    IncorrectAnswers int                `json:"incorrect_answers"`
64
    AccuracyRate     float64            `json:"accuracy_rate"`
65
    AnsweredByType   map[string]int     `json:"answered_by_type"`
66
    AnsweredByLevel  map[string]int     `json:"answered_by_level"`
67
    AccuracyByType   map[string]float64 `json:"accuracy_by_type"`
68
    AccuracyByLevel  map[string]float64 `json:"accuracy_by_level"`
69
    AvailableByType  map[string]int     `json:"available_by_type"`
70
    AvailableByLevel map[string]int     `json:"available_by_level"`
71
    RecentlyAnswered int                `json:"recently_answered"` // Within last hour
72
}
73

74
// contextutils.ErrQuestionNotFound is returned when a question does not exist in the database
75
// contextutils.ErrQuestionNotFound is now imported from contextutils
76

77
// LearningService provides methods for managing user learning progress
78
type LearningService struct {
79
    db     *sql.DB
80
    cfg    *config.Config
81
    logger *observability.Logger
82
}
83

84
// NewLearningServiceWithLogger creates a new LearningService with a logger
85
99x
func NewLearningServiceWithLogger(db *sql.DB, cfg *config.Config, logger *observability.Logger) *LearningService {
86
99x
    return &LearningService{
87
99x
        db:     db,
88
99x
        cfg:    cfg,
89
99x
        logger: logger,
90
99x
    }
91
99x
}
92

93
// RecordUserResponse records a user's response to a question and updates metrics
94
36x
func (s *LearningService) RecordUserResponse(ctx context.Context, response *models.UserResponse) (err error) {
95
36x
    ctx, span := observability.TraceLearningFunction(ctx, "record_user_response",
96
36x
        observability.AttributeUserID(response.UserID),
97
36x
        observability.AttributeQuestionID(response.QuestionID),
98
36x
        attribute.Bool("response.is_correct", response.IsCorrect),
99
36x
        attribute.Int("response.time_ms", response.ResponseTimeMs),
100
36x
    )
101
36x
    defer observability.FinishSpan(span, &err)
102
36x

103
36x
    query := `
104
36x
        INSERT INTO user_responses (user_id, question_id, user_answer_index, is_correct, response_time_ms)
105
36x
        VALUES ($1, $2, $3, $4, $5) RETURNING id
106
36x
    `
107
36x

108
36x
    var id int
109
36x
    err = s.db.QueryRowContext(ctx, query,
110
36x
        response.UserID,
111
36x
        response.QuestionID,
112
36x
        response.UserAnswerIndex,
113
36x
        response.IsCorrect,
114
36x
        response.ResponseTimeMs,
115
36x
    ).Scan(&id)
116
36x
    if err != nil {
117
1x
        return err
118
1x
    }
119

120
35x
    response.ID = id
121
35x

122
35x
    // Update performance metrics
123
35x
    return s.updatePerformanceMetrics(ctx, response)
124
}
125

126
35x
func (s *LearningService) updatePerformanceMetrics(ctx context.Context, response *models.UserResponse) (err error) {
127
35x
    ctx, span := observability.TraceLearningFunction(ctx, "update_performance_metrics",
128
35x
        observability.AttributeUserID(response.UserID),
129
35x
        observability.AttributeQuestionID(response.QuestionID),
130
35x
        attribute.Bool("response.is_correct", response.IsCorrect),
131
35x
    )
132
35x
    defer observability.FinishSpan(span, &err)
133
35x

134
35x
    // Get question details
135
35x
    var question *models.Question
136
35x
    question, err = s.getQuestionDetails(ctx, response.QuestionID)
137
35x
    if err != nil {
138
        return err
139
    }
140

141
    // Update or create performance metrics
142
35x
    query := `
143
35x
        INSERT INTO performance_metrics (
144
35x
            user_id, topic, language, level, total_attempts, correct_attempts,
145
35x
            average_response_time_ms, difficulty_adjustment, last_updated
146
35x
        )
147
35x
        VALUES ($1, $2, $3, $4, 1, $5, $6, 0.0, CURRENT_TIMESTAMP)
148
35x
        ON CONFLICT(user_id, topic, language, level) DO UPDATE SET
149
35x
            total_attempts = performance_metrics.total_attempts + 1,
150
35x
            correct_attempts = performance_metrics.correct_attempts + $7,
151
35x
            average_response_time_ms = (performance_metrics.average_response_time_ms * (performance_metrics.total_attempts - 1) + $8) / performance_metrics.total_attempts,
152
35x
            last_updated = CURRENT_TIMESTAMP
153
35x
    `
154
35x

155
35x
    correctIncrement := 0
156
35x
    if response.IsCorrect {
157
18x
        correctIncrement = 1
158
18x
    }
159

160
35x
    _, err = s.db.ExecContext(ctx, query,
161
35x
        response.UserID,
162
35x
        question.TopicCategory,
163
35x
        question.Language,
164
35x
        question.Level,
165
35x
        correctIncrement,                 // For initial correct_attempts in VALUES
166
35x
        float64(response.ResponseTimeMs), // For initial average_response_time_ms in VALUES
167
35x
        correctIncrement,                 // For correct_attempts increment in UPDATE
168
35x
        response.ResponseTimeMs,          // For average_response_time_ms calculation in UPDATE
169
35x
    )
170
35x

171
35x
    return err
172
}
173

174
// getUserByID is a lightweight helper for LearningService to fetch a user row.
175
4x
func (s *LearningService) getUserByID(ctx context.Context, userID int) (*models.User, error) {
176
4x
    query := `
177
4x
        SELECT id, username, email, timezone, password_hash, last_active,
178
4x
               preferred_language, current_level, ai_provider, ai_model,
179
4x
               ai_enabled, ai_api_key, created_at, updated_at
180
4x
        FROM users
181
4x
        WHERE id = $1
182
4x
    `
183
4x

184
4x
    var u models.User
185
4x
    err := s.db.QueryRowContext(ctx, query, userID).Scan(
186
4x
        &u.ID, &u.Username, &u.Email, &u.Timezone, &u.PasswordHash, &u.LastActive,
187
4x
        &u.PreferredLanguage, &u.CurrentLevel, &u.AIProvider, &u.AIModel,
188
4x
        &u.AIEnabled, &u.AIAPIKey, &u.CreatedAt, &u.UpdatedAt,
189
4x
    )
190
4x
    if err != nil {
191
        if err == sql.ErrNoRows {
192
            return nil, nil
193
        }
194
        return nil, err
195
    }
196
4x
    return &u, nil
197
}
198

199
35x
func (s *LearningService) getQuestionDetails(ctx context.Context, questionID int) (result0 *models.Question, err error) {
200
35x
    ctx, span := observability.TraceLearningFunction(ctx, "get_question_details",
201
35x
        observability.AttributeQuestionID(questionID),
202
35x
    )
203
35x
    defer observability.FinishSpan(span, &err)
204
35x

205
35x
    query := `SELECT type, language, level, topic_category FROM questions WHERE id = $1`
206
35x

207
35x
    question := &models.Question{}
208
35x
    var topicCategory sql.NullString
209
35x
    err = s.db.QueryRowContext(ctx, query, questionID).Scan(
210
35x
        &question.Type,
211
35x
        &question.Language,
212
35x
        &question.Level,
213
35x
        &topicCategory,
214
35x
    )
215
35x

216
35x
    if topicCategory.Valid {
217
31x
        question.TopicCategory = topicCategory.String
218
31x
    }
219

220
35x
    return question, err
221
}
222

223
// GetUserProgress retrieves comprehensive learning progress for a user
224
1x
func (s *LearningService) GetUserProgress(ctx context.Context, userID int) (result0 *models.UserProgress, err error) {
225
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_progress",
226
1x
        attribute.String("user.username", ""),
227
1x
        attribute.String("language", ""),
228
1x
        attribute.String("level", ""),
229
1x
    )
230
1x
    defer observability.FinishSpan(span, &err)
231
1x

232
1x
    progress := &models.UserProgress{
233
1x
        PerformanceByTopic: make(map[string]*models.PerformanceMetrics),
234
1x
    }
235
1x

236
1x
    // Get overall stats
237
1x
    overallQuery := `
238
1x
        SELECT
239
1x
            COUNT(*) as total,
240
1x
            COALESCE(SUM(CASE WHEN is_correct THEN 1 ELSE 0 END), 0) as correct
241
1x
        FROM user_responses
242
1x
        WHERE user_id = $1
243
1x
    `
244
1x

245
1x
    err = s.db.QueryRowContext(ctx, overallQuery, userID).Scan(
246
1x
        &progress.TotalQuestions,
247
1x
        &progress.CorrectAnswers,
248
1x
    )
249
1x

250
1x
    if err != nil && err != sql.ErrNoRows {
251
        return nil, err
252
    }
253

254
1x
    if progress.TotalQuestions > 0 {
255
1x
        progress.AccuracyRate = float64(progress.CorrectAnswers) / float64(progress.TotalQuestions) * 100
256
1x
    }
257

258
    // Get performance by topic
259
1x
    metricsQuery := `
260
1x
        SELECT id, topic, language, level, total_attempts, correct_attempts,
261
1x
               average_response_time_ms, difficulty_adjustment, last_updated
262
1x
        FROM performance_metrics
263
1x
        WHERE user_id = $1
264
1x
    `
265
1x

266
1x
    rows, err := s.db.QueryContext(ctx, metricsQuery, userID)
267
1x
    if err != nil {
268
        return nil, err
269
    }
270
1x
    defer func() {
271
1x
        if err := rows.Close(); err != nil {
272
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
273
        }
274
    }()
275

276
1x
    for rows.Next() {
277
1x
        metric := &models.PerformanceMetrics{UserID: userID}
278
1x
        err = rows.Scan(
279
1x
            &metric.ID,
280
1x
            &metric.Topic,
281
1x
            &metric.Language,
282
1x
            &metric.Level,
283
1x
            &metric.TotalAttempts,
284
1x
            &metric.CorrectAttempts,
285
1x
            &metric.AverageResponseTimeMs,
286
1x
            &metric.DifficultyAdjustment,
287
1x
            &metric.LastUpdated,
288
1x
        )
289
1x
        if err != nil {
290
            return nil, err
291
        }
292

293
1x
        key := metric.Topic + "_" + metric.Language + "_" + metric.Level
294
1x
        progress.PerformanceByTopic[key] = metric
295
    }
296

297
    // Identify weak areas (accuracy < 60%)
298
1x
    progress.WeakAreas = s.identifyWeakAreas(progress.PerformanceByTopic)
299
1x

300
1x
    // Get recent activity
301
1x
    progress.RecentActivity, err = s.getRecentActivity(ctx, userID, 10)
302
1x
    if err != nil {
303
        return nil, err
304
    }
305

306
    // Get current level from user
307
1x
    currentLevel, err := s.getCurrentUserLevel(ctx, userID)
308
1x
    if err != nil {
309
        return nil, err
310
    }
311
1x
    progress.CurrentLevel = currentLevel
312
1x

313
1x
    // Suggest level adjustment if needed
314
1x
    progress.SuggestedLevel = s.suggestLevelAdjustment(progress)
315
1x

316
1x
    return progress, nil
317
}
318

319
1x
func (s *LearningService) identifyWeakAreas(metrics map[string]*models.PerformanceMetrics) []string {
320
1x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
321
1x
    // But we could add tracing if we want to track the analysis performance
322
1x
    var weakAreas []string
323
1x

324
1x
    for key, metric := range metrics {
325
1x
        if metric.TotalAttempts > 0 && metric.AccuracyRate() < 60.0 && metric.TotalAttempts >= 3 {
326
            weakAreas = append(weakAreas, key)
327
        }
328
    }
329

330
1x
    return weakAreas
331
}
332

333
1x
func (s *LearningService) getRecentActivity(ctx context.Context, userID, limit int) (result0 []models.UserResponse, err error) {
334
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_recent_activity",
335
1x
        observability.AttributeUserID(userID),
336
1x
        attribute.Int("limit", limit),
337
1x
    )
338
1x
    defer observability.FinishSpan(span, &err)
339
1x

340
1x
    query := `
341
1x
        SELECT id, user_id, question_id, user_answer_index, is_correct, response_time_ms, created_at
342
1x
        FROM user_responses
343
1x
        WHERE user_id = $1
344
1x
        ORDER BY created_at DESC
345
1x
        LIMIT $2
346
1x
    `
347
1x

348
1x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
349
1x
    if err != nil {
350
        return nil, err
351
    }
352
1x
    defer func() {
353
1x
        if err := rows.Close(); err != nil {
354
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
355
        }
356
    }()
357

358
1x
    var responses []models.UserResponse
359
1x
    for rows.Next() {
360
3x
        var response models.UserResponse
361
3x
        err = rows.Scan(
362
3x
            &response.ID,
363
3x
            &response.UserID,
364
3x
            &response.QuestionID,
365
3x
            &response.UserAnswerIndex,
366
3x
            &response.IsCorrect,
367
3x
            &response.ResponseTimeMs,
368
3x
            &response.CreatedAt,
369
3x
        )
370
3x
        if err != nil {
371
            return nil, err
372
        }
373

374
3x
        responses = append(responses, response)
375
    }
376

377
1x
    return responses, nil
378
}
379

380
1x
func (s *LearningService) getCurrentUserLevel(ctx context.Context, userID int) (result0 string, err error) {
381
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_current_user_level",
382
1x
        observability.AttributeUserID(userID),
383
1x
    )
384
1x
    defer observability.FinishSpan(span, &err)
385
1x

386
1x
    query := `SELECT current_level FROM users WHERE id = $1`
387
1x

388
1x
    var level sql.NullString
389
1x
    err = s.db.QueryRowContext(ctx, query, userID).Scan(&level)
390
1x
    if err != nil {
391
        return "", err
392
    }
393

394
    // Return default level if NULL
395
1x
    if !level.Valid || level.String == "" {
396
        return "A1", nil // Default level
397
    }
398

399
1x
    return level.String, nil
400
}
401

402
13x
func (s *LearningService) suggestLevelAdjustment(progress *models.UserProgress) string {
403
13x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
404
13x
    // But we could add tracing if we want to track the analysis performance
405
13x
    if progress.TotalQuestions < 20 {
406
3x
        return "" // Not enough data
407
3x
    }
408

409
    // If accuracy is consistently high (>85%), suggest level up
410
5x
    if progress.AccuracyRate > 85.0 {
411
2x
        return s.getNextLevel(progress.CurrentLevel)
412
2x
    }
413

414
    // If accuracy is consistently low (<50%), suggest level down
415
3x
    if progress.AccuracyRate < 50.0 {
416
2x
        return s.getPreviousLevel(progress.CurrentLevel)
417
2x
    }
418

419
1x
    return ""
420
}
421

422
10x
func (s *LearningService) getNextLevel(currentLevel string) string {
423
10x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
424
10x
    levels := s.cfg.GetAllLevels()
425
10x

426
10x
    for i, level := range levels {
427
45x
        if level == currentLevel && i < len(levels)-1 {
428
8x
            return levels[i+1]
429
8x
        }
430
    }
431

432
2x
    return currentLevel
433
}
434

435
10x
func (s *LearningService) getPreviousLevel(currentLevel string) string {
436
10x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
437
10x
    levels := s.cfg.GetAllLevels()
438
10x

439
10x
    for i, level := range levels {
440
54x
        if level == currentLevel && i > 0 {
441
8x
            return levels[i-1]
442
8x
        }
443
    }
444

445
2x
    return currentLevel
446
}
447

448
// GetWeakestTopics returns the topics where the user performs poorest
449
1x
func (s *LearningService) GetWeakestTopics(ctx context.Context, userID, limit int) (result0 []*models.PerformanceMetrics, err error) {
450
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_weakest_topics",
451
1x
        observability.AttributeUserID(userID),
452
1x
        attribute.Int("limit", limit),
453
1x
    )
454
1x
    defer observability.FinishSpan(span, &err)
455
1x

456
1x
    query := `
457
1x
        SELECT id, topic, language, level, total_attempts, correct_attempts, average_response_time_ms, difficulty_adjustment, last_updated
458
1x
        FROM performance_metrics
459
1x
        WHERE user_id = $1 AND total_attempts >= 3
460
1x
        ORDER BY (correct_attempts * 1.0 / total_attempts) ASC, last_updated ASC
461
1x
        LIMIT $2
462
1x
    `
463
1x

464
1x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
465
1x
    if err != nil {
466
        return nil, err
467
    }
468
1x
    defer func() {
469
1x
        if err := rows.Close(); err != nil {
470
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
471
        }
472
    }()
473

474
1x
    var topics []*models.PerformanceMetrics
475
1x
    for rows.Next() {
476
3x
        metric := &models.PerformanceMetrics{UserID: userID}
477
3x
        err = rows.Scan(
478
3x
            &metric.ID,
479
3x
            &metric.Topic,
480
3x
            &metric.Language,
481
3x
            &metric.Level,
482
3x
            &metric.TotalAttempts,
483
3x
            &metric.CorrectAttempts,
484
3x
            &metric.AverageResponseTimeMs,
485
3x
            &metric.DifficultyAdjustment,
486
3x
            &metric.LastUpdated,
487
3x
        )
488
3x
        if err != nil {
489
            return nil, err
490
        }
491
3x
        topics = append(topics, metric)
492
    }
493

494
1x
    return topics, nil
495
}
496

497
// ShouldAvoidQuestion determines if a question should be avoided for a user
498
4x
func (s *LearningService) ShouldAvoidQuestion(ctx context.Context, userID, questionID int) (result0 bool, err error) {
499
4x
    ctx, span := observability.TraceLearningFunction(ctx, "should_avoid_question",
500
4x
        observability.AttributeUserID(userID),
501
4x
        observability.AttributeQuestionID(questionID),
502
4x
    )
503
4x
    defer observability.FinishSpan(span, &err)
504
4x

505
4x
    // Determine user's local 1-day window and convert to UTC timestamps
506
4x
    startUTC, endUTC, _, err := contextutils.UserLocalDayRange(ctx, userID, 1, s.getUserByID)
507
4x
    if err != nil {
508
        return false, contextutils.WrapError(err, "failed to compute user local day range")
509
    }
510

511
4x
    query := `
512
4x
        SELECT COUNT(*)
513
4x
        FROM user_responses
514
4x
        WHERE user_id = $1 AND question_id = $2 AND is_correct = true
515
4x
        AND created_at >= $3 AND created_at < $4
516
4x
    `
517
4x

518
4x
    var count int
519
4x
    err = s.db.QueryRowContext(ctx, query, userID, questionID, startUTC, endUTC).Scan(&count)
520
4x

521
4x
    span.SetAttributes(attribute.Bool("should_avoid", count > 0))
522
4x
    return count > 0, err
523
}
524

525
// GetUserQuestionStats returns comprehensive per-user question statistics
526
1x
func (s *LearningService) GetUserQuestionStats(ctx context.Context, userID int) (result0 *UserQuestionStats, err error) {
527
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_question_stats",
528
1x
        observability.AttributeUserID(userID),
529
1x
    )
530
1x
    defer observability.FinishSpan(span, &err)
531
1x

532
1x
    stats := &UserQuestionStats{
533
1x
        UserID:           userID,
534
1x
        AnsweredByType:   make(map[string]int),
535
1x
        AnsweredByLevel:  make(map[string]int),
536
1x
        AccuracyByType:   make(map[string]float64),
537
1x
        AccuracyByLevel:  make(map[string]float64),
538
1x
        AvailableByType:  make(map[string]int),
539
1x
        AvailableByLevel: make(map[string]int),
540
1x
    }
541
1x

542
1x
    // Get user's language and level preferences
543
1x
    var userLanguage, userLevel string
544
1x
    userQuery := `SELECT COALESCE(preferred_language, 'italian'), COALESCE(current_level, 'B1') FROM users WHERE id = $1`
545
1x
    err = s.db.QueryRowContext(ctx, userQuery, userID).Scan(&userLanguage, &userLevel)
546
1x
    if err != nil {
547
        return nil, err
548
    }
549

550
1x
    span.SetAttributes(
551
1x
        attribute.String("user.language", userLanguage),
552
1x
        attribute.String("user.level", userLevel),
553
1x
    )
554
1x

555
1x
    // Get questions answered by user with stats
556
1x
    answeredQuery := `
557
1x
        SELECT
558
1x
            q.type,
559
1x
            q.level,
560
1x
            COUNT(*) as total,
561
1x
            SUM(CASE WHEN ur.is_correct THEN 1 ELSE 0 END) as correct
562
1x
        FROM user_responses ur
563
1x
        JOIN questions q ON ur.question_id = q.id
564
1x
        WHERE ur.user_id = $1
565
1x
        GROUP BY q.type, q.level
566
1x
    `
567
1x

568
1x
    rows, err := s.db.QueryContext(ctx, answeredQuery, userID)
569
1x
    if err != nil {
570
        return nil, err
571
    }
572
1x
    defer func() {
573
1x
        if err := rows.Close(); err != nil {
574
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
575
        }
576
    }()
577

578
1x
    for rows.Next() {
579
1x
        var qType, level string
580
1x
        var total, correct int
581
1x

582
1x
        if err := rows.Scan(&qType, &level, &total, &correct); err != nil {
583
            return nil, err
584
        }
585

586
1x
        stats.AnsweredByType[qType] += total
587
1x
        stats.AnsweredByLevel[level] += total
588
1x
        stats.TotalAnswered += total
589
1x

590
1x
        // Calculate accuracy rates
591
1x
        accuracy := float64(correct) / float64(total) * 100
592
1x

593
1x
        // For type accuracy, we need to aggregate across levels
594
1x
        if _, exists := stats.AnsweredByType[qType]; exists {
595
1x
            // Recalculate accuracy for this type
596
1x
            typeQuery := `
597
1x
                SELECT
598
1x
                    COUNT(*) as total,
599
1x
                    SUM(CASE WHEN ur.is_correct THEN 1 ELSE 0 END) as correct
600
1x
                FROM user_responses ur
601
1x
                JOIN questions q ON ur.question_id = q.id
602
1x
                WHERE ur.user_id = $1 AND q.type = $2
603
1x
            `
604
1x
            var typeTotal, typeCorrect int
605
1x
            if err := s.db.QueryRowContext(ctx, typeQuery, userID, qType).Scan(&typeTotal, &typeCorrect); err != nil {
606
                s.logger.Warn(ctx, "Failed to scan type query result", map[string]interface{}{"error": err.Error()})
607
            }
608
1x
            if typeTotal > 0 {
609
1x
                stats.AccuracyByType[qType] = float64(typeCorrect) / float64(typeTotal) * 100
610
1x
            }
611
        } else {
612
            stats.AccuracyByType[qType] = accuracy
613
        }
614

615
        // For level accuracy
616
1x
        if _, exists := stats.AnsweredByLevel[level]; exists {
617
1x
            // Recalculate accuracy for this level
618
1x
            levelQuery := `
619
1x
                SELECT
620
1x
                    COUNT(*) as total,
621
1x
                    SUM(CASE WHEN ur.is_correct THEN 1 ELSE 0 END) as correct
622
1x
                FROM user_responses ur
623
1x
                JOIN questions q ON ur.question_id = q.id
624
1x
                WHERE ur.user_id = $1 AND q.level = $2
625
1x
            `
626
1x
            var levelTotal, levelCorrect int
627
1x
            if err := s.db.QueryRowContext(ctx, levelQuery, userID, level).Scan(&levelTotal, &levelCorrect); err != nil {
628
                s.logger.Warn(ctx, "Failed to scan level query result", map[string]interface{}{"error": err.Error()})
629
            }
630
1x
            if levelTotal > 0 {
631
1x
                stats.AccuracyByLevel[level] = float64(levelCorrect) / float64(levelTotal) * 100
632
1x
            }
633
        } else {
634
            stats.AccuracyByLevel[level] = accuracy
635
        }
636
    }
637

638
    // Get available questions (not answered by user) that belong to this user
639
1x
    availableQuery := `
640
1x
        SELECT
641
1x
            q.type,
642
1x
            q.level,
643
1x
            COUNT(*) as available
644
1x
        FROM questions q
645
1x
        JOIN user_questions uq ON uq.question_id = q.id
646
1x
        WHERE uq.user_id = $1
647
1x
        AND q.language = $2
648
1x
        AND q.status = 'active'
649
1x
        AND q.id NOT IN (
650
1x
            SELECT DISTINCT question_id
651
1x
            FROM user_responses
652
1x
            WHERE user_id = $3
653
1x
        )
654
1x
        GROUP BY q.type, q.level
655
1x
    `
656
1x

657
1x
    rows, err = s.db.QueryContext(ctx, availableQuery, userID, userLanguage, userID)
658
1x
    if err != nil {
659
        return nil, err
660
    }
661
1x
    defer func() {
662
1x
        if err := rows.Close(); err != nil {
663
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
664
        }
665
    }()
666

667
1x
    for rows.Next() {
668
        var qType, level string
669
        var available int
670

671
        if err := rows.Scan(&qType, &level, &available); err != nil {
672
            return nil, err
673
        }
674

675
        stats.AvailableByType[qType] += available
676
        stats.AvailableByLevel[level] += available
677
    }
678

679
    // Get recently answered questions (within last hour)
680
1x
    recentQuery := `
681
1x
        SELECT COUNT(*)
682
1x
        FROM user_responses ur
683
1x
        WHERE ur.user_id = $1
684
1x
        AND ur.created_at > NOW() - INTERVAL '1 hour'
685
1x
    `
686
1x

687
1x
    err = s.db.QueryRowContext(ctx, recentQuery, userID).Scan(&stats.RecentlyAnswered)
688
1x
    if err != nil {
689
        stats.RecentlyAnswered = 0 // Default to 0 if query fails
690
    }
691

692
    // Calculate overall correct/incorrect answers and accuracy rate
693
1x
    overallQuery := `
694
1x
        SELECT
695
1x
            COUNT(*) as total,
696
1x
            SUM(CASE WHEN is_correct THEN 1 ELSE 0 END) as correct
697
1x
        FROM user_responses
698
1x
        WHERE user_id = $1
699
1x
    `
700
1x

701
1x
    var total, correct int
702
1x
    err = s.db.QueryRowContext(ctx, overallQuery, userID).Scan(&total, &correct)
703
1x
    if err != nil {
704
        // Default values if query fails
705
        stats.CorrectAnswers = 0
706
        stats.IncorrectAnswers = 0
707
        stats.AccuracyRate = 0.0
708
    } else {
709
1x
        stats.CorrectAnswers = correct
710
1x
        stats.IncorrectAnswers = total - correct
711
1x
        if total > 0 {
712
1x
            stats.AccuracyRate = float64(correct) / float64(total) * 100
713
1x
        } else {
714
            stats.AccuracyRate = 0.0
715
        }
716
    }
717

718
1x
    return stats, nil
719
}
720

721
// PRIORITY SYSTEM METHODS
722

723
// RecordAnswerWithPriority records a user's response and updates priority scores
724
1x
func (s *LearningService) RecordAnswerWithPriority(ctx context.Context, userID, questionID, answerIndex int, isCorrect bool, responseTime int) error {
725
1x
    // Create user response object
726
1x
    response := &models.UserResponse{
727
1x
        UserID:          userID,
728
1x
        QuestionID:      questionID,
729
1x
        UserAnswerIndex: answerIndex,
730
1x
        IsCorrect:       isCorrect,
731
1x
        ResponseTimeMs:  responseTime,
732
1x
        CreatedAt:       time.Now(),
733
1x
    }
734
1x

735
1x
    // Use existing RecordUserResponse method
736
1x
    err := s.RecordUserResponse(ctx, response)
737
1x
    if err != nil {
738
        return contextutils.WrapError(err, "failed to record user response")
739
    }
740

741
    // Update priority score in background
742
1x
    go s.updatePriorityScoreAsync(ctx, userID, questionID)
743
1x

744
1x
    return nil
745
}
746

747
// RecordAnswerWithPriorityReturningID records a user's response, updates priority async, and returns the new user_responses ID
748
2x
func (s *LearningService) RecordAnswerWithPriorityReturningID(ctx context.Context, userID, questionID, answerIndex int, isCorrect bool, responseTime int) (int, error) {
749
2x
    response := &models.UserResponse{
750
2x
        UserID:          userID,
751
2x
        QuestionID:      questionID,
752
2x
        UserAnswerIndex: answerIndex,
753
2x
        IsCorrect:       isCorrect,
754
2x
        ResponseTimeMs:  responseTime,
755
2x
        CreatedAt:       time.Now(),
756
2x
    }
757
2x

758
2x
    // Insert and get ID
759
2x
    if err := s.RecordUserResponse(ctx, response); err != nil {
760
        return 0, contextutils.WrapError(err, "failed to record user response")
761
    }
762

763
    // Update priority score in background
764
2x
    go s.updatePriorityScoreAsync(ctx, userID, questionID)
765
2x

766
2x
    return response.ID, nil
767
}
768

769
// MarkQuestionAsKnown marks a question as known for a user with optional confidence level
770
12x
func (s *LearningService) MarkQuestionAsKnown(ctx context.Context, userID, questionID int, confidenceLevel *int) (err error) {
771
12x
    ctx, span := observability.TraceLearningFunction(ctx, "mark_question_as_known",
772
12x
        observability.AttributeUserID(userID),
773
12x
        observability.AttributeQuestionID(questionID),
774
12x
    )
775
12x
    defer observability.FinishSpan(span, &err)
776
12x

777
12x
    // DEBUG: Log the attempt
778
12x
    s.logger.Debug(ctx, "MarkQuestionAsKnown called", map[string]interface{}{
779
12x
        "user_id":     userID,
780
12x
        "question_id": questionID,
781
12x
    })
782
12x

783
12x
    // Update user_question_metadata table with confidence level
784
12x
    _, err = s.db.ExecContext(ctx, `
785
12x
        INSERT INTO user_question_metadata (user_id, question_id, marked_as_known, marked_as_known_at, confidence_level, created_at, updated_at)
786
12x
        VALUES ($1, $2, TRUE, NOW(), $3, NOW(), NOW())
787
12x
        ON CONFLICT (user_id, question_id) DO UPDATE
788
12x
        SET marked_as_known = TRUE, marked_as_known_at = NOW(), confidence_level = $3, updated_at = NOW()
789
12x
    `, userID, questionID, confidenceLevel)
790
12x
    if err != nil {
791
        // DEBUG: Log the actual error
792
        s.logger.Debug(ctx, "MarkQuestionAsKnown error", map[string]interface{}{
793
            "user_id":     userID,
794
            "question_id": questionID,
795
            "error":       err.Error(),
796
            "error_type":  fmt.Sprintf("%T", err),
797
        })
798

799
        if isForeignKeyConstraintViolation(err) {
800
            s.logger.Debug(ctx, "Foreign key constraint violation detected", map[string]interface{}{
801
                "user_id":     userID,
802
                "question_id": questionID,
803
            })
804
            return contextutils.ErrQuestionNotFound
805
        }
806
        s.logger.Debug(ctx, "Not a foreign key constraint violation, returning original error", map[string]interface{}{
807
            "user_id":     userID,
808
            "question_id": questionID,
809
        })
810
        return err
811
    }
812

813
12x
    s.logger.Debug(ctx, "MarkQuestionAsKnown succeeded", map[string]interface{}{
814
12x
        "user_id":     userID,
815
12x
        "question_id": questionID,
816
12x
    })
817
12x

818
12x
    // Update priority score in background so the new confidence affects selection immediately
819
12x
    go s.updatePriorityScoreAsync(ctx, userID, questionID)
820
12x
    return nil
821
}
822

823
// GetUserLearningPreferences retrieves user learning preferences
824
332x
func (s *LearningService) GetUserLearningPreferences(ctx context.Context, userID int) (result0 *models.UserLearningPreferences, err error) {
825
332x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_learning_preferences",
826
332x
        observability.AttributeUserID(userID),
827
332x
    )
828
332x
    defer observability.FinishSpan(span, &err)
829
332x

830
332x
    var prefs models.UserLearningPreferences
831
332x
    err = s.db.QueryRowContext(ctx, `
832
332x
        SELECT id, user_id, focus_on_weak_areas, include_review_questions, fresh_question_ratio,
833
332x
               known_question_penalty, review_interval_days, weak_area_boost, daily_reminder_enabled,
834
332x
               tts_voice, last_daily_reminder_sent, daily_goal, created_at, updated_at
835
332x
        FROM user_learning_preferences
836
332x
        WHERE user_id = $1
837
332x
    `, userID).Scan(
838
332x
        &prefs.ID, &prefs.UserID, &prefs.FocusOnWeakAreas, &prefs.IncludeReviewQuestions,
839
332x
        &prefs.FreshQuestionRatio, &prefs.KnownQuestionPenalty, &prefs.ReviewIntervalDays,
840
332x
        &prefs.WeakAreaBoost, &prefs.DailyReminderEnabled,
841
332x
        &prefs.TTSVoice,
842
332x
        &prefs.LastDailyReminderSent,
843
332x
        &prefs.DailyGoal,
844
332x
        &prefs.CreatedAt, &prefs.UpdatedAt,
845
332x
    )
846
332x

847
332x
    if err == sql.ErrNoRows {
848
31x
        // Check if user exists before creating default preferences
849
31x
        var userExists bool
850
31x
        err = s.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)", userID).Scan(&userExists)
851
31x
        if err != nil {
852
4x
            return nil, contextutils.WrapError(err, "failed to check if user exists")
853
4x
        }
854
27x
        if !userExists {
855
            return nil, contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "user %d not found", userID)
856
        }
857
        // Create default preferences if none exist
858
27x
        return s.createDefaultPreferences(ctx, userID)
859
    }
860

861
301x
    if err != nil {
862
        return nil, contextutils.WrapError(err, "failed to get user preferences")
863
    }
864

865
301x
    return &prefs, nil
866
}
867

868
// UpdateLastDailyReminderSent updates the last daily reminder sent timestamp for a user
869
3x
func (s *LearningService) UpdateLastDailyReminderSent(ctx context.Context, userID int) (err error) {
870
3x
    ctx, span := observability.TraceLearningFunction(ctx, "update_last_daily_reminder_sent",
871
3x
        observability.AttributeUserID(userID),
872
3x
    )
873
3x
    defer observability.FinishSpan(span, &err)
874
3x

875
3x
    // Use INSERT ... ON CONFLICT to create the record if it doesn't exist
876
3x
    _, err = s.db.ExecContext(ctx, `
877
3x
        INSERT INTO user_learning_preferences (user_id, last_daily_reminder_sent, updated_at)
878
3x
        VALUES ($1, NOW(), NOW())
879
3x
        ON CONFLICT (user_id) DO UPDATE SET
880
3x
            last_daily_reminder_sent = NOW(),
881
3x
            updated_at = NOW()
882
3x
    `, userID)
883
3x
    if err != nil {
884
        return contextutils.WrapError(err, "failed to update last daily reminder sent")
885
    }
886

887
3x
    return nil
888
}
889

890
// UpdateUserLearningPreferences updates user learning preferences
891
9x
func (s *LearningService) UpdateUserLearningPreferences(ctx context.Context, userID int, prefs *models.UserLearningPreferences) (result0 *models.UserLearningPreferences, err error) {
892
9x
    ctx, span := observability.TraceLearningFunction(ctx, "update_user_learning_preferences",
893
9x
        observability.AttributeUserID(userID),
894
9x
        attribute.Bool("prefs.focus_on_weak_areas", prefs.FocusOnWeakAreas),
895
9x
        attribute.Bool("prefs.include_review_questions", prefs.IncludeReviewQuestions),
896
9x
        attribute.Float64("prefs.fresh_question_ratio", prefs.FreshQuestionRatio),
897
9x
        attribute.Float64("prefs.known_question_penalty", prefs.KnownQuestionPenalty),
898
9x
        attribute.Int("prefs.review_interval_days", prefs.ReviewIntervalDays),
899
9x
        attribute.Float64("prefs.weak_area_boost", prefs.WeakAreaBoost),
900
9x
    )
901
9x
    defer func() {
902
9x
        if err != nil {
903
            span.RecordError(err, trace.WithStackTrace(true))
904
            span.SetStatus(codes.Error, err.Error())
905
        }
906
9x
        span.End()
907
    }()
908

909
9x
    var updatedPrefs models.UserLearningPreferences
910
9x
    err = s.db.QueryRowContext(ctx, `
911
9x
        UPDATE user_learning_preferences
912
9x
        SET focus_on_weak_areas = $2, include_review_questions = $3, fresh_question_ratio = $4,
913
9x
            known_question_penalty = $5, review_interval_days = $6, weak_area_boost = $7,
914
9x
            daily_reminder_enabled = $8, tts_voice = $9, daily_goal = COALESCE(NULLIF($10, 0), daily_goal), updated_at = NOW()
915
9x
        WHERE user_id = $1
916
9x
        RETURNING id, user_id, focus_on_weak_areas, include_review_questions, fresh_question_ratio,
917
9x
                  known_question_penalty, review_interval_days, weak_area_boost, daily_reminder_enabled,
918
9x
                  tts_voice, last_daily_reminder_sent, daily_goal, created_at, updated_at
919
9x
    `, userID, prefs.FocusOnWeakAreas, prefs.IncludeReviewQuestions, prefs.FreshQuestionRatio,
920
9x
        prefs.KnownQuestionPenalty, prefs.ReviewIntervalDays, prefs.WeakAreaBoost, prefs.DailyReminderEnabled, prefs.TTSVoice, prefs.DailyGoal).Scan(
921
9x
        &updatedPrefs.ID, &updatedPrefs.UserID, &updatedPrefs.FocusOnWeakAreas, &updatedPrefs.IncludeReviewQuestions,
922
9x
        &updatedPrefs.FreshQuestionRatio, &updatedPrefs.KnownQuestionPenalty, &updatedPrefs.ReviewIntervalDays,
923
9x
        &updatedPrefs.WeakAreaBoost, &updatedPrefs.DailyReminderEnabled, &updatedPrefs.TTSVoice, &updatedPrefs.LastDailyReminderSent,
924
9x
        &updatedPrefs.DailyGoal, &updatedPrefs.CreatedAt, &updatedPrefs.UpdatedAt,
925
9x
    )
926
9x

927
9x
    if err == sql.ErrNoRows {
928
7x
        // If no preferences exist, create them with the provided values
929
7x
        return s.createPreferencesWithValues(ctx, userID, prefs)
930
7x
    }
931

932
2x
    if err != nil {
933
        return nil, contextutils.WrapError(err, "failed to update user preferences")
934
    }
935

936
2x
    return &updatedPrefs, nil
937
}
938

939
// createPreferencesWithValues creates learning preferences for a user with the provided values
940
7x
func (s *LearningService) createPreferencesWithValues(ctx context.Context, userID int, prefs *models.UserLearningPreferences) (result0 *models.UserLearningPreferences, err error) {
941
7x
    ctx, span := observability.TraceLearningFunction(ctx, "create_preferences_with_values",
942
7x
        observability.AttributeUserID(userID),
943
7x
    )
944
7x
    defer func() {
945
7x
        if err != nil {
946
            span.RecordError(err, trace.WithStackTrace(true))
947
            span.SetStatus(codes.Error, err.Error())
948
        }
949
7x
        span.End()
950
    }()
951

952
    // Use the provided values, falling back to defaults for any missing fields
953
7x
    defaultPrefs := s.GetDefaultLearningPreferences()
954
7x
    prefs.UserID = userID
955
7x

956
7x
    // Merge provided values with defaults
957
7x
    if prefs.FocusOnWeakAreas == defaultPrefs.FocusOnWeakAreas && !prefs.FocusOnWeakAreas {
958
        prefs.FocusOnWeakAreas = defaultPrefs.FocusOnWeakAreas
959
    }
960
7x
    if prefs.IncludeReviewQuestions == defaultPrefs.IncludeReviewQuestions && !prefs.IncludeReviewQuestions {
961
        prefs.IncludeReviewQuestions = defaultPrefs.IncludeReviewQuestions
962
    }
963
7x
    if prefs.FreshQuestionRatio == 0 {
964
2x
        prefs.FreshQuestionRatio = defaultPrefs.FreshQuestionRatio
965
2x
    }
966
7x
    if prefs.KnownQuestionPenalty == 0 {
967
2x
        prefs.KnownQuestionPenalty = defaultPrefs.KnownQuestionPenalty
968
2x
    }
969
7x
    if prefs.ReviewIntervalDays == 0 {
970
2x
        prefs.ReviewIntervalDays = defaultPrefs.ReviewIntervalDays
971
2x
    }
972
7x
    if prefs.WeakAreaBoost == 0 {
973
2x
        prefs.WeakAreaBoost = defaultPrefs.WeakAreaBoost
974
2x
    }
975
7x
    if prefs.DailyGoal == 0 {
976
7x
        prefs.DailyGoal = defaultPrefs.DailyGoal
977
7x
    }
978

979
    // Try to insert with ON CONFLICT DO NOTHING to handle race conditions
980
7x
    _, err = s.db.ExecContext(ctx, `
981
7x
        INSERT INTO user_learning_preferences (user_id, focus_on_weak_areas, include_review_questions,
982
7x
                                               fresh_question_ratio, known_question_penalty,
983
7x
                                               review_interval_days, weak_area_boost, daily_reminder_enabled,
984
7x
                                               tts_voice, daily_goal, created_at, updated_at)
985
7x
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
986
7x
        ON CONFLICT (user_id) DO NOTHING
987
7x
    `, userID, prefs.FocusOnWeakAreas, prefs.IncludeReviewQuestions,
988
7x
        prefs.FreshQuestionRatio, prefs.KnownQuestionPenalty,
989
7x
        prefs.ReviewIntervalDays, prefs.WeakAreaBoost, prefs.DailyReminderEnabled, prefs.TTSVoice, prefs.DailyGoal)
990
7x
    if err != nil {
991
        return nil, contextutils.WrapError(err, "failed to create preferences with values")
992
    }
993

994
    // Now fetch the preferences (either the ones we just created or the ones created by another concurrent request)
995
7x
    err = s.db.QueryRowContext(ctx, `
996
7x
        SELECT id, user_id, focus_on_weak_areas, include_review_questions, fresh_question_ratio,
997
7x
               known_question_penalty, review_interval_days, weak_area_boost, daily_reminder_enabled,
998
7x
               tts_voice, last_daily_reminder_sent, daily_goal, created_at, updated_at
999
7x
        FROM user_learning_preferences
1000
7x
        WHERE user_id = $1
1001
7x
    `, userID).Scan(
1002
7x
        &prefs.ID, &prefs.UserID, &prefs.FocusOnWeakAreas, &prefs.IncludeReviewQuestions,
1003
7x
        &prefs.FreshQuestionRatio, &prefs.KnownQuestionPenalty, &prefs.ReviewIntervalDays,
1004
7x
        &prefs.WeakAreaBoost, &prefs.DailyReminderEnabled, &prefs.TTSVoice, &prefs.LastDailyReminderSent,
1005
7x
        &prefs.DailyGoal, &prefs.CreatedAt, &prefs.UpdatedAt,
1006
7x
    )
1007
7x
    if err != nil {
1008
        return nil, contextutils.WrapError(err, "failed to fetch created preferences")
1009
    }
1010

1011
7x
    return prefs, nil
1012
}
1013

1014
// createDefaultPreferences creates default learning preferences for a user
1015
27x
func (s *LearningService) createDefaultPreferences(ctx context.Context, userID int) (result0 *models.UserLearningPreferences, err error) {
1016
27x
    ctx, span := observability.TraceLearningFunction(ctx, "create_default_preferences",
1017
27x
        observability.AttributeUserID(userID),
1018
27x
    )
1019
27x
    defer func() {
1020
27x
        if err != nil {
1021
1x
            span.RecordError(err, trace.WithStackTrace(true))
1022
1x
            span.SetStatus(codes.Error, err.Error())
1023
1x
        }
1024
27x
        span.End()
1025
    }()
1026

1027
27x
    defaultPrefs := s.GetDefaultLearningPreferences()
1028
27x
    defaultPrefs.UserID = userID
1029
27x

1030
27x
    // Try to insert with ON CONFLICT DO NOTHING to handle race conditions
1031
27x
    _, err = s.db.ExecContext(ctx, `
1032
27x
        INSERT INTO user_learning_preferences (user_id, focus_on_weak_areas, include_review_questions,
1033
27x
                                               fresh_question_ratio, known_question_penalty,
1034
27x
                                               review_interval_days, weak_area_boost, daily_reminder_enabled,
1035
27x
                                               tts_voice, daily_goal, created_at, updated_at)
1036
27x
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
1037
27x
        ON CONFLICT (user_id) DO NOTHING
1038
27x
    `, userID, defaultPrefs.FocusOnWeakAreas, defaultPrefs.IncludeReviewQuestions,
1039
27x
        defaultPrefs.FreshQuestionRatio, defaultPrefs.KnownQuestionPenalty,
1040
27x
        defaultPrefs.ReviewIntervalDays, defaultPrefs.WeakAreaBoost, defaultPrefs.DailyReminderEnabled, defaultPrefs.TTSVoice, defaultPrefs.DailyGoal)
1041
27x
    if err != nil {
1042
1x
        return nil, contextutils.WrapError(err, "failed to create default preferences")
1043
1x
    }
1044

1045
    // Now fetch the preferences (either the ones we just created or the ones created by another concurrent request)
1046
26x
    err = s.db.QueryRowContext(ctx, `
1047
26x
        SELECT id, user_id, focus_on_weak_areas, include_review_questions, fresh_question_ratio,
1048
26x
               known_question_penalty, review_interval_days, weak_area_boost, daily_reminder_enabled,
1049
26x
               tts_voice, last_daily_reminder_sent, daily_goal, created_at, updated_at
1050
26x
        FROM user_learning_preferences
1051
26x
        WHERE user_id = $1
1052
26x
    `, userID).Scan(
1053
26x
        &defaultPrefs.ID, &defaultPrefs.UserID, &defaultPrefs.FocusOnWeakAreas, &defaultPrefs.IncludeReviewQuestions,
1054
26x
        &defaultPrefs.FreshQuestionRatio, &defaultPrefs.KnownQuestionPenalty, &defaultPrefs.ReviewIntervalDays,
1055
26x
        &defaultPrefs.WeakAreaBoost, &defaultPrefs.DailyReminderEnabled, &defaultPrefs.TTSVoice, &defaultPrefs.LastDailyReminderSent,
1056
26x
        &defaultPrefs.DailyGoal, &defaultPrefs.CreatedAt, &defaultPrefs.UpdatedAt,
1057
26x
    )
1058
26x
    if err != nil {
1059
        return nil, contextutils.WrapError(err, "failed to fetch created preferences")
1060
    }
1061

1062
26x
    return defaultPrefs, nil
1063
}
1064

1065
// GetDefaultLearningPreferences returns default learning preferences
1066
34x
func (s *LearningService) GetDefaultLearningPreferences() *models.UserLearningPreferences {
1067
34x
    return &models.UserLearningPreferences{
1068
34x
        FocusOnWeakAreas:       true,
1069
34x
        IncludeReviewQuestions: true,
1070
34x
        FreshQuestionRatio:     0.3,
1071
34x
        KnownQuestionPenalty:   0.1,
1072
34x
        ReviewIntervalDays:     7,
1073
34x
        WeakAreaBoost:          2.0,
1074
34x
        DailyReminderEnabled:   false, // Default to false for daily reminders
1075
34x
        DailyGoal:              10,
1076
34x
        TTSVoice:               "",
1077
34x
    }
1078
34x
}
1079

1080
// CalculatePriorityScore calculates priority score for a specific question for a user
1081
28x
func (s *LearningService) CalculatePriorityScore(ctx context.Context, userID, questionID int) (result0 float64, err error) {
1082
28x
    ctx, span := observability.TraceLearningFunction(ctx, "calculate_priority_score",
1083
28x
        observability.AttributeUserID(userID),
1084
28x
        observability.AttributeQuestionID(questionID),
1085
28x
    )
1086
28x
    defer func() {
1087
28x
        if err != nil {
1088
7x
            span.RecordError(err, trace.WithStackTrace(true))
1089
7x
            span.SetStatus(codes.Error, err.Error())
1090
7x
        }
1091
28x
        span.End()
1092
    }()
1093

1094
    // Get user preferences
1095
28x
    prefs, err := s.GetUserLearningPreferences(ctx, userID)
1096
28x
    if err != nil {
1097
5x
        return 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get user preferences: %w", err)
1098
5x
    }
1099

1100
    // Get user's performance history for this question
1101
23x
    performance, err := s.getQuestionPerformance(ctx, userID, questionID)
1102
23x
    if err != nil {
1103
2x
        return 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get question performance: %w", err)
1104
2x
    }
1105

1106
    // Calculate components
1107
21x
    baseScore := 100.0
1108
21x
    performanceMultiplier := s.calculatePerformanceMultiplier(performance, prefs.WeakAreaBoost)
1109
21x
    spacedRepetitionBoost := s.calculateSpacedRepetitionBoost(performance.LastSeenAt)
1110
21x
    userPreferenceMultiplier := s.calculateUserPreferenceMultiplier(performance, prefs)
1111
21x
    freshnessBoost := s.calculateFreshnessBoost(performance.TimesAnswered)
1112
21x

1113
21x
    // Final score with bounds checking
1114
21x
    finalScore := baseScore * performanceMultiplier * spacedRepetitionBoost * userPreferenceMultiplier * freshnessBoost
1115
21x

1116
21x
    // Apply bounds to prevent extreme values
1117
21x
    if finalScore < 1.0 {
1118
        finalScore = 1.0
1119
    } else if finalScore > 1000.0 {
1120
        finalScore = 1000.0
1121
    }
1122

1123
21x
    return finalScore, nil
1124
}
1125

1126
// updatePriorityScoreAsync updates priority score for a question asynchronously
1127
16x
func (s *LearningService) updatePriorityScoreAsync(ctx context.Context, userID, questionID int) {
1128
16x
    ctx, span := observability.TraceLearningFunction(ctx, "update_priority_score_async",
1129
16x
        observability.AttributeUserID(userID),
1130
16x
        observability.AttributeQuestionID(questionID),
1131
16x
    )
1132
16x
    defer span.End()
1133
16x

1134
16x
    score, err := s.CalculatePriorityScore(ctx, userID, questionID)
1135
16x
    if err != nil {
1136
7x
        s.logger.Error(ctx, "Failed to calculate priority score", err, map[string]interface{}{
1137
7x
            "user_id":     userID,
1138
7x
            "question_id": questionID,
1139
7x
        })
1140
7x
        return
1141
7x
    }
1142

1143
    // Update or insert priority score
1144
9x
    _, err = s.db.ExecContext(ctx, `
1145
9x
        INSERT INTO question_priority_scores (user_id, question_id, priority_score, last_calculated_at, created_at, updated_at)
1146
9x
        VALUES ($1, $2, $3, NOW(), NOW(), NOW())
1147
9x
        ON CONFLICT (user_id, question_id) DO UPDATE
1148
9x
        SET priority_score = $3, last_calculated_at = NOW(), updated_at = NOW()
1149
9x
    `, userID, questionID, score)
1150
9x
    if err != nil {
1151
        s.logger.Error(ctx, "Failed to update priority score", err, map[string]interface{}{
1152
            "user_id":     userID,
1153
            "question_id": questionID,
1154
            "score":       score,
1155
        })
1156
    }
1157
}
1158

1159
// QuestionPerformance represents performance data for a specific question
1160
type QuestionPerformance struct {
1161
    TimesAnswered   int
1162
    CorrectAnswers  int
1163
    LastSeenAt      *time.Time
1164
    MarkedAsKnown   bool
1165
    MarkedAsKnownAt *time.Time
1166
    ConfidenceLevel *int
1167
}
1168

1169
// getQuestionPerformance retrieves performance data for a specific question
1170
23x
func (s *LearningService) getQuestionPerformance(ctx context.Context, userID, questionID int) (result0 *QuestionPerformance, err error) {
1171
23x
    ctx, span := observability.TraceLearningFunction(ctx, "get_question_performance",
1172
23x
        observability.AttributeUserID(userID),
1173
23x
        observability.AttributeQuestionID(questionID),
1174
23x
    )
1175
23x
    defer func() {
1176
23x
        if err != nil {
1177
2x
            span.RecordError(err, trace.WithStackTrace(true))
1178
2x
            span.SetStatus(codes.Error, err.Error())
1179
2x
        }
1180
23x
        span.End()
1181
    }()
1182

1183
23x
    performance := &QuestionPerformance{}
1184
23x

1185
23x
    // Get response statistics
1186
23x
    err = s.db.QueryRowContext(ctx, `
1187
23x
        SELECT
1188
23x
            COUNT(*) as times_answered,
1189
23x
            COALESCE(SUM(CASE WHEN is_correct THEN 1 ELSE 0 END), 0) as correct_answers,
1190
23x
            MAX(created_at) as last_seen_at
1191
23x
        FROM user_responses
1192
23x
        WHERE user_id = $1 AND question_id = $2
1193
23x
    `, userID, questionID).Scan(
1194
23x
        &performance.TimesAnswered,
1195
23x
        &performance.CorrectAnswers,
1196
23x
        &performance.LastSeenAt,
1197
23x
    )
1198
23x

1199
23x
    if err != nil && err != sql.ErrNoRows {
1200
1x
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get response statistics: %w", err)
1201
1x
    }
1202

1203
    // Get metadata
1204
22x
    var markedAsKnownAt sql.NullTime
1205
22x
    var confidenceLevel sql.NullInt32
1206
22x
    err = s.db.QueryRowContext(ctx, `
1207
22x
        SELECT marked_as_known, marked_as_known_at, confidence_level
1208
22x
        FROM user_question_metadata
1209
22x
        WHERE user_id = $1 AND question_id = $2
1210
22x
    `, userID, questionID).Scan(&performance.MarkedAsKnown, &markedAsKnownAt, &confidenceLevel)
1211
22x

1212
22x
    if err != nil && err != sql.ErrNoRows {
1213
1x
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get question metadata: %w", err)
1214
1x
    }
1215

1216
21x
    if markedAsKnownAt.Valid {
1217
15x
        performance.MarkedAsKnownAt = &markedAsKnownAt.Time
1218
15x
    }
1219

1220
21x
    if confidenceLevel.Valid {
1221
15x
        level := int(confidenceLevel.Int32)
1222
15x
        performance.ConfidenceLevel = &level
1223
15x
    }
1224

1225
21x
    return performance, nil
1226
}
1227

1228
// calculatePerformanceMultiplier calculates the performance-based multiplier
1229
21x
func (s *LearningService) calculatePerformanceMultiplier(performance *QuestionPerformance, weakAreaBoost float64) float64 {
1230
21x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
1231
21x
    if performance.TimesAnswered == 0 {
1232
16x
        return 1.0 // Neutral for new questions
1233
16x
    }
1234

1235
5x
    errorRate := float64(performance.TimesAnswered-performance.CorrectAnswers) / float64(performance.TimesAnswered)
1236
5x
    successRate := float64(performance.CorrectAnswers) / float64(performance.TimesAnswered)
1237
5x

1238
5x
    // Apply weak area boost for questions with high error rates
1239
5x
    multiplier := 1.0 + (errorRate * weakAreaBoost) - (successRate * 0.5)
1240
5x

1241
5x
    // Apply bounds to prevent extreme values
1242
5x
    if multiplier < 0.1 {
1243
        multiplier = 0.1
1244
    } else if multiplier > 10.0 {
1245
        multiplier = 10.0
1246
    }
1247

1248
5x
    return multiplier
1249
}
1250

1251
// calculateSpacedRepetitionBoost calculates the spaced repetition boost
1252
21x
func (s *LearningService) calculateSpacedRepetitionBoost(lastSeenAt *time.Time) float64 {
1253
21x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
1254
21x
    if lastSeenAt == nil {
1255
16x
        return 1.0 // No boost for never-seen questions
1256
16x
    }
1257

1258
5x
    daysSinceLastSeen := time.Since(*lastSeenAt).Hours() / 24.0
1259
5x
    boost := 1.0 + (daysSinceLastSeen * 0.1)
1260
5x

1261
5x
    // Cap the boost at 5.0x multiplier
1262
5x
    return math.Min(boost, 5.0)
1263
}
1264

1265
// calculateUserPreferenceMultiplier calculates how user preference ("mark known" with confidence)
1266
// influences question priority.
1267
//
1268
// New policy:
1269
// - Confidence 1â2: show MORE (boost priority) â multipliers > 1
1270
// - Confidence 3: neutral â multiplier = 1
1271
// - Confidence 4â5: show LESS (reduce priority) â multiplier < 1 using KnownQuestionPenalty
1272
21x
func (s *LearningService) calculateUserPreferenceMultiplier(performance *QuestionPerformance, prefs *models.UserLearningPreferences) float64 {
1273
21x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
1274
21x
    if performance.MarkedAsKnown {
1275
15x
        if performance.ConfidenceLevel != nil {
1276
15x
            switch *performance.ConfidenceLevel {
1277
4x
            case 1:
1278
4x
                // Low confidence â increase frequency noticeably
1279
4x
                return 1.25
1280
1x
            case 2:
1281
1x
                // Some confidence â slight increase in frequency
1282
1x
                return 1.10
1283
3x
            case 3:
1284
3x
                // Neutral â no change
1285
3x
                return 1.0
1286
2x
            case 4:
1287
2x
                // Very confident â decrease frequency using half of penalty
1288
2x
                return prefs.KnownQuestionPenalty * 0.5
1289
5x
            case 5:
1290
5x
                // Extremely confident â strong decrease using 10% of penalty
1291
5x
                return prefs.KnownQuestionPenalty * 0.1
1292
            default:
1293
                return 1.0
1294
            }
1295
        }
1296
        // Fallback when confidence not provided â use configured penalty
1297
        return prefs.KnownQuestionPenalty
1298
    }
1299
6x
    return 1.0
1300
}
1301

1302
// calculateFreshnessBoost calculates the freshness boost for new questions
1303
21x
func (s *LearningService) calculateFreshnessBoost(timesAnswered int) float64 {
1304
21x
    // Note: This is a pure function that doesn't need tracing since it doesn't make external calls
1305
21x
    if timesAnswered == 0 {
1306
16x
        return 1.5 // Boost for fresh questions
1307
16x
    }
1308
5x
    return 1.0
1309
}
1310

1311
// isForeignKeyConstraintViolation checks if the error is a foreign key constraint violation
1312
func isForeignKeyConstraintViolation(err error) bool {
1313
    if err == nil {
1314
        return false
1315
    }
1316

1317
    // Check for PostgreSQL foreign key constraint violation error code
1318
    if pqErr, ok := err.(*pq.Error); ok {
1319
        // PostgreSQL error code 23503 is for foreign key constraint violations
1320
        if pqErr.Code == "23503" {
1321
            return true
1322
        }
1323
    }
1324

1325
    // Also check for the error message pattern as a fallback
1326
    errorStr := err.Error()
1327
    return strings.Contains(errorStr, "violates foreign key constraint")
1328
}
1329

1330
// Analytics Methods
1331

1332
// GetPriorityScoreDistribution returns the distribution of priority scores
1333
1x
func (s *LearningService) GetPriorityScoreDistribution(ctx context.Context) (result0 map[string]interface{}, err error) {
1334
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_priority_score_distribution")
1335
1x
    defer func() {
1336
1x
        if err != nil {
1337
            span.RecordError(err, trace.WithStackTrace(true))
1338
            span.SetStatus(codes.Error, err.Error())
1339
        }
1340
1x
        span.End()
1341
    }()
1342

1343
1x
    query := `
1344
1x
        SELECT
1345
1x
            COUNT(CASE WHEN qps.priority_score > 200 THEN 1 END) as high,
1346
1x
            COUNT(CASE WHEN qps.priority_score BETWEEN 100 AND 200 THEN 1 END) as medium,
1347
1x
            COUNT(CASE WHEN qps.priority_score < 100 THEN 1 END) as low,
1348
1x
            AVG(qps.priority_score) as average
1349
1x
        FROM question_priority_scores qps
1350
1x
        JOIN questions q ON qps.question_id = q.id
1351
1x
        WHERE qps.priority_score > 0
1352
1x
    `
1353
1x

1354
1x
    var high, medium, low int
1355
1x
    var average sql.NullFloat64
1356
1x

1357
1x
    err = s.db.QueryRowContext(ctx, query).Scan(&high, &medium, &low, &average)
1358
1x
    if err != nil {
1359
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get priority score distribution: %w", err)
1360
    }
1361

1362
1x
    result := map[string]interface{}{
1363
1x
        "high":    high,
1364
1x
        "medium":  medium,
1365
1x
        "low":     low,
1366
1x
        "average": 0.0,
1367
1x
    }
1368
1x

1369
1x
    if average.Valid {
1370
1x
        result["average"] = average.Float64
1371
1x
    }
1372

1373
1x
    span.SetAttributes(
1374
1x
        attribute.Int("high_count", high),
1375
1x
        attribute.Int("medium_count", medium),
1376
1x
        attribute.Int("low_count", low),
1377
1x
        attribute.Float64("average_score", result["average"].(float64)),
1378
1x
    )
1379
1x

1380
1x
    return result, nil
1381
}
1382

1383
// GetHighPriorityQuestions returns the highest priority questions
1384
1x
func (s *LearningService) GetHighPriorityQuestions(ctx context.Context, limit int) (result0 []map[string]interface{}, err error) {
1385
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_high_priority_questions",
1386
1x
        attribute.Int("limit", limit),
1387
1x
    )
1388
1x
    defer func() {
1389
1x
        if err != nil {
1390
            span.RecordError(err, trace.WithStackTrace(true))
1391
            span.SetStatus(codes.Error, err.Error())
1392
        }
1393
1x
        span.End()
1394
    }()
1395

1396
1x
    query := `
1397
1x
        SELECT
1398
1x
            q.type as question_type,
1399
1x
            q.level,
1400
1x
            q.topic_category as topic,
1401
1x
            qps.priority_score
1402
1x
        FROM question_priority_scores qps
1403
1x
        JOIN questions q ON qps.question_id = q.id
1404
1x
        WHERE qps.priority_score > 200
1405
1x
        ORDER BY qps.priority_score DESC
1406
1x
        LIMIT $1
1407
1x
    `
1408
1x

1409
1x
    rows, err := s.db.QueryContext(ctx, query, limit)
1410
1x
    if err != nil {
1411
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get high priority questions: %w", err)
1412
    }
1413
1x
    defer func() {
1414
1x
        if err := rows.Close(); err != nil {
1415
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1416
        }
1417
    }()
1418

1419
1x
    var questions []map[string]interface{}
1420
1x
    for rows.Next() {
1421
3x
        var questionType, level, topic sql.NullString
1422
3x
        var priorityScore float64
1423
3x

1424
3x
        err = rows.Scan(&questionType, &level, &topic, &priorityScore)
1425
3x
        if err != nil {
1426
            continue
1427
        }
1428

1429
3x
        question := map[string]interface{}{
1430
3x
            "question_type":  questionType.String,
1431
3x
            "level":          level.String,
1432
3x
            "topic":          topic.String,
1433
3x
            "priority_score": priorityScore,
1434
3x
        }
1435
3x
        questions = append(questions, question)
1436
    }
1437

1438
1x
    span.SetAttributes(attribute.Int("questions_count", len(questions)))
1439
1x
    return questions, nil
1440
}
1441

1442
// GetWeakAreasByTopic returns weak areas by topic
1443
1x
func (s *LearningService) GetWeakAreasByTopic(ctx context.Context, limit int) (result0 []map[string]interface{}, err error) {
1444
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_weak_areas_by_topic",
1445
1x
        attribute.Int("limit", limit),
1446
1x
    )
1447
1x
    defer func() {
1448
1x
        if err != nil {
1449
            span.RecordError(err, trace.WithStackTrace(true))
1450
            span.SetStatus(codes.Error, err.Error())
1451
        }
1452
1x
        span.End()
1453
    }()
1454

1455
1x
    query := `
1456
1x
        SELECT
1457
1x
            topic,
1458
1x
            SUM(total_attempts) as total_attempts,
1459
1x
            SUM(correct_attempts) as correct_attempts
1460
1x
        FROM performance_metrics
1461
1x
        WHERE total_attempts > 0
1462
1x
        GROUP BY topic
1463
1x
        ORDER BY (SUM(correct_attempts)::float / SUM(total_attempts)) ASC
1464
1x
        LIMIT $1
1465
1x
    `
1466
1x

1467
1x
    rows, err := s.db.QueryContext(ctx, query, limit)
1468
1x
    if err != nil {
1469
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get weak areas: %w", err)
1470
    }
1471
1x
    defer func() {
1472
1x
        if err := rows.Close(); err != nil {
1473
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1474
        }
1475
    }()
1476

1477
1x
    var weakAreas []map[string]interface{}
1478
1x
    for rows.Next() {
1479
1x
        var topic sql.NullString
1480
1x
        var totalAttempts, correctAttempts int
1481
1x

1482
1x
        err = rows.Scan(&topic, &totalAttempts, &correctAttempts)
1483
1x
        if err != nil {
1484
            continue
1485
        }
1486

1487
1x
        area := map[string]interface{}{
1488
1x
            "topic":            topic.String,
1489
1x
            "total_attempts":   totalAttempts,
1490
1x
            "correct_attempts": correctAttempts,
1491
1x
        }
1492
1x
        weakAreas = append(weakAreas, area)
1493
    }
1494

1495
1x
    span.SetAttributes(attribute.Int("weak_areas_count", len(weakAreas)))
1496
1x
    return weakAreas, nil
1497
}
1498

1499
// GetLearningPreferencesUsage returns learning preferences usage statistics
1500
1x
func (s *LearningService) GetLearningPreferencesUsage(ctx context.Context) (result0 map[string]interface{}, err error) {
1501
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_learning_preferences_usage")
1502
1x
    defer func() {
1503
1x
        if err != nil {
1504
            span.RecordError(err, trace.WithStackTrace(true))
1505
            span.SetStatus(codes.Error, err.Error())
1506
        }
1507
1x
        span.End()
1508
    }()
1509

1510
1x
    query := `
1511
1x
        SELECT
1512
1x
            COUNT(*) as total_users,
1513
1x
            AVG(focus_on_weak_areas::int) as avg_focus_on_weak_areas,
1514
1x
            AVG(fresh_question_ratio) as avg_fresh_question_ratio,
1515
1x
            AVG(weak_area_boost) as avg_weak_area_boost,
1516
1x
            AVG(known_question_penalty) as avg_known_question_penalty
1517
1x
        FROM user_learning_preferences
1518
1x
    `
1519
1x

1520
1x
    var totalUsers int
1521
1x
    var avgFocusOnWeakAreas, avgFreshQuestionRatio, avgWeakAreaBoost, avgKnownQuestionPenalty sql.NullFloat64
1522
1x

1523
1x
    err = s.db.QueryRowContext(ctx, query).Scan(
1524
1x
        &totalUsers,
1525
1x
        &avgFocusOnWeakAreas,
1526
1x
        &avgFreshQuestionRatio,
1527
1x
        &avgWeakAreaBoost,
1528
1x
        &avgKnownQuestionPenalty,
1529
1x
    )
1530
1x
    if err != nil {
1531
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get learning preferences usage: %w", err)
1532
    }
1533

1534
1x
    result := map[string]interface{}{
1535
1x
        "total_users":          0,
1536
1x
        "focusOnWeakAreas":     false,
1537
1x
        "freshQuestionRatio":   0.3,
1538
1x
        "weakAreaBoost":        2.0,
1539
1x
        "knownQuestionPenalty": 0.1,
1540
1x
    }
1541
1x

1542
1x
    if totalUsers > 0 {
1543
1x
        result["total_users"] = totalUsers
1544
1x
        if avgFocusOnWeakAreas.Valid {
1545
1x
            result["focusOnWeakAreas"] = avgFocusOnWeakAreas.Float64 > 0.5
1546
1x
        }
1547
1x
        if avgFreshQuestionRatio.Valid {
1548
1x
            result["freshQuestionRatio"] = avgFreshQuestionRatio.Float64
1549
1x
        }
1550
1x
        if avgWeakAreaBoost.Valid {
1551
1x
            result["weakAreaBoost"] = avgWeakAreaBoost.Float64
1552
1x
        }
1553
1x
        if avgKnownQuestionPenalty.Valid {
1554
1x
            result["knownQuestionPenalty"] = avgKnownQuestionPenalty.Float64
1555
1x
        }
1556
    }
1557

1558
1x
    span.SetAttributes(
1559
1x
        attribute.Int("total_users", result["total_users"].(int)),
1560
1x
        attribute.Bool("focus_on_weak_areas", result["focusOnWeakAreas"].(bool)),
1561
1x
        attribute.Float64("fresh_question_ratio", result["freshQuestionRatio"].(float64)),
1562
1x
        attribute.Float64("weak_area_boost", result["weakAreaBoost"].(float64)),
1563
1x
        attribute.Float64("known_question_penalty", result["knownQuestionPenalty"].(float64)),
1564
1x
    )
1565
1x

1566
1x
    return result, nil
1567
}
1568

1569
// GetQuestionTypeGaps returns gaps in question types
1570
1x
func (s *LearningService) GetQuestionTypeGaps(ctx context.Context) (result0 []map[string]interface{}, err error) {
1571
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_question_type_gaps")
1572
1x
    defer func() {
1573
1x
        if err != nil {
1574
            span.RecordError(err, trace.WithStackTrace(true))
1575
            span.SetStatus(codes.Error, err.Error())
1576
        }
1577
1x
        span.End()
1578
    }()
1579

1580
1x
    query := `
1581
1x
        SELECT
1582
1x
            q.type as question_type,
1583
1x
            q.level,
1584
1x
            COUNT(q.id) as available,
1585
1x
            COUNT(qps.question_id) as with_priority_scores
1586
1x
        FROM questions q
1587
1x
        LEFT JOIN question_priority_scores qps ON q.id = qps.question_id
1588
1x
        GROUP BY q.type, q.level
1589
1x
        HAVING COUNT(qps.question_id) < COUNT(q.id) * 0.8
1590
1x
        ORDER BY (COUNT(qps.question_id)::float / COUNT(q.id)) ASC
1591
1x
    `
1592
1x

1593
1x
    rows, err := s.db.QueryContext(ctx, query)
1594
1x
    if err != nil {
1595
        span.SetAttributes(attribute.String("error.type", "database_query_failed"), attribute.String("error", err.Error()))
1596
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get question type gaps: %w", err)
1597
    }
1598
1x
    defer func() {
1599
1x
        if err := rows.Close(); err != nil {
1600
            s.logger.Warn(ctx, "Failed to close rows in GetQuestionTypeGaps", map[string]interface{}{"error": err.Error()})
1601
        }
1602
    }()
1603

1604
1x
    var gaps []map[string]interface{}
1605
1x
    var scanErrors int
1606
1x

1607
1x
    for rows.Next() {
1608
3x
        var questionType, level sql.NullString
1609
3x
        var available, withPriorityScores int
1610
3x

1611
3x
        err = rows.Scan(&questionType, &level, &available, &withPriorityScores)
1612
3x
        if err != nil {
1613
            scanErrors++
1614
            span.SetAttributes(attribute.String("error.type", "row_scan_failed"), attribute.String("error", err.Error()))
1615
            continue
1616
        }
1617

1618
3x
        gap := map[string]interface{}{
1619
3x
            "question_type": questionType.String,
1620
3x
            "level":         level.String,
1621
3x
            "available":     available,
1622
3x
            "demand":        available - withPriorityScores,
1623
3x
        }
1624
3x
        gaps = append(gaps, gap)
1625
    }
1626

1627
1x
    if err := rows.Err(); err != nil {
1628
        span.SetAttributes(attribute.String("error.type", "rows_iteration_failed"), attribute.String("error", err.Error()))
1629
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "error during rows iteration: %w", err)
1630
    }
1631

1632
1x
    span.SetAttributes(
1633
1x
        attribute.Int("gaps_count", len(gaps)),
1634
1x
        attribute.Int("scan_errors", scanErrors),
1635
1x
    )
1636
1x
    return gaps, nil
1637
}
1638

1639
// GetGenerationSuggestions returns suggestions for question generation
1640
1x
func (s *LearningService) GetGenerationSuggestions(ctx context.Context) (result0 []map[string]interface{}, err error) {
1641
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_generation_suggestions")
1642
1x
    defer func() {
1643
1x
        if err != nil {
1644
            span.RecordError(err, trace.WithStackTrace(true))
1645
            span.SetStatus(codes.Error, err.Error())
1646
        }
1647
1x
        span.End()
1648
    }()
1649

1650
1x
    query := `
1651
1x
        SELECT
1652
1x
            q.type as question_type,
1653
1x
            q.level,
1654
1x
            q.language,
1655
1x
            COUNT(q.id) as available,
1656
1x
            COUNT(CASE WHEN qps.priority_score > 100 THEN 1 END) as high_priority,
1657
1x
            AVG(qps.priority_score) as avg_priority
1658
1x
        FROM questions q
1659
1x
        LEFT JOIN question_priority_scores qps ON q.id = qps.question_id
1660
1x
        GROUP BY q.type, q.level, q.language
1661
1x
        HAVING COUNT(q.id) < 50 OR COUNT(CASE WHEN qps.priority_score > 100 THEN 1 END) < 10
1662
1x
        ORDER BY COUNT(q.id) ASC, AVG(qps.priority_score) DESC
1663
1x
    `
1664
1x

1665
1x
    rows, err := s.db.QueryContext(ctx, query)
1666
1x
    if err != nil {
1667
        span.SetAttributes(attribute.String("error.type", "database_query_failed"), attribute.String("error", err.Error()))
1668
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get generation suggestions: %w", err)
1669
    }
1670
1x
    defer func() {
1671
1x
        if err := rows.Close(); err != nil {
1672
            s.logger.Warn(ctx, "Failed to close rows in GetGenerationSuggestions", map[string]interface{}{"error": err.Error()})
1673
        }
1674
    }()
1675

1676
1x
    var suggestions []map[string]interface{}
1677
1x
    var scanErrors int
1678
1x

1679
1x
    for rows.Next() {
1680
1x
        var questionType, level, language sql.NullString
1681
1x
        var available, highPriority int
1682
1x
        var avgPriority sql.NullFloat64
1683
1x

1684
1x
        err = rows.Scan(&questionType, &level, &language, &available, &highPriority, &avgPriority)
1685
1x
        if err != nil {
1686
            scanErrors++
1687
            span.SetAttributes(attribute.String("error.type", "row_scan_failed"), attribute.String("error", err.Error()))
1688
            continue
1689
        }
1690

1691
1x
        suggestion := map[string]interface{}{
1692
1x
            "question_type":  questionType.String,
1693
1x
            "level":          level.String,
1694
1x
            "language":       language.String,
1695
1x
            "available":      available,
1696
1x
            "high_priority":  highPriority,
1697
1x
            "avg_priority":   0.0,
1698
1x
            "priority_score": 0.0,
1699
1x
        }
1700
1x

1701
1x
        if avgPriority.Valid {
1702
            suggestion["avg_priority"] = avgPriority.Float64
1703
            suggestion["priority_score"] = avgPriority.Float64
1704
        }
1705

1706
1x
        suggestions = append(suggestions, suggestion)
1707
    }
1708

1709
1x
    if err := rows.Err(); err != nil {
1710
        span.SetAttributes(attribute.String("error.type", "rows_iteration_failed"), attribute.String("error", err.Error()))
1711
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "error during rows iteration: %w", err)
1712
    }
1713

1714
1x
    span.SetAttributes(
1715
1x
        attribute.Int("suggestions_count", len(suggestions)),
1716
1x
        attribute.Int("scan_errors", scanErrors),
1717
1x
    )
1718
1x
    return suggestions, nil
1719
}
1720

1721
// GetPrioritySystemPerformance returns performance metrics for the priority system
1722
1x
func (s *LearningService) GetPrioritySystemPerformance(ctx context.Context) (result0 map[string]interface{}, err error) {
1723
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_priority_system_performance")
1724
1x
    defer func() {
1725
1x
        if err != nil {
1726
            span.RecordError(err, trace.WithStackTrace(true))
1727
            span.SetStatus(codes.Error, err.Error())
1728
        }
1729
1x
        span.End()
1730
    }()
1731

1732
    // This is a simplified implementation - in a real system, this would track actual performance metrics
1733
1x
    query := `
1734
1x
        SELECT
1735
1x
            COUNT(*) as total_calculations,
1736
1x
            AVG(priority_score) as avg_score,
1737
1x
            MAX(last_calculated_at) as last_calculation
1738
1x
        FROM question_priority_scores
1739
1x
        WHERE last_calculated_at > NOW() - INTERVAL '1 hour'
1740
1x
    `
1741
1x

1742
1x
    var totalCalculations int
1743
1x
    var avgScore sql.NullFloat64
1744
1x
    var lastCalculation sql.NullTime
1745
1x

1746
1x
    err = s.db.QueryRowContext(ctx, query).Scan(&totalCalculations, &avgScore, &lastCalculation)
1747
1x
    if err != nil {
1748
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get priority system performance: %w", err)
1749
    }
1750

1751
1x
    result := map[string]interface{}{
1752
1x
        "calculationsPerSecond": float64(totalCalculations) / 3600.0, // Per hour converted to per second
1753
1x
        "avgCalculationTime":    0.0,                                 // Would need to track actual calculation times
1754
1x
        "avgQueryTime":          0.0,                                 // Would need to track actual query times
1755
1x
        "memoryUsage":           0.0,                                 // Would need to track actual memory usage
1756
1x
        "avgScore":              0.0,                                 // Default value
1757
1x
    }
1758
1x

1759
1x
    if avgScore.Valid {
1760
        result["avgScore"] = avgScore.Float64
1761
    }
1762

1763
1x
    if lastCalculation.Valid {
1764
        result["lastCalculation"] = lastCalculation.Time.Format(time.RFC3339)
1765
    }
1766

1767
1x
    span.SetAttributes(
1768
1x
        attribute.Float64("calculations_per_second", result["calculationsPerSecond"].(float64)),
1769
1x
        attribute.Float64("avg_score", result["avgScore"].(float64)),
1770
1x
        attribute.Int("total_calculations", totalCalculations),
1771
1x
    )
1772
1x

1773
1x
    return result, nil
1774
}
1775

1776
// GetBackgroundJobsStatus returns the status of background jobs
1777
1x
func (s *LearningService) GetBackgroundJobsStatus(ctx context.Context) (result0 map[string]interface{}, err error) {
1778
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_background_jobs_status")
1779
1x
    defer func() {
1780
1x
        if err != nil {
1781
            span.RecordError(err, trace.WithStackTrace(true))
1782
            span.SetStatus(codes.Error, err.Error())
1783
        }
1784
1x
        span.End()
1785
    }()
1786

1787
    // This is a simplified implementation - in a real system, this would track actual background job status
1788
1x
    query := `
1789
1x
        SELECT
1790
1x
            COUNT(*) as total_updates,
1791
1x
            MAX(updated_at) as last_update
1792
1x
        FROM question_priority_scores
1793
1x
        WHERE updated_at > NOW() - INTERVAL '1 minute'
1794
1x
    `
1795
1x

1796
1x
    var totalUpdates int
1797
1x
    var lastUpdate sql.NullTime
1798
1x

1799
1x
    err = s.db.QueryRowContext(ctx, query).Scan(&totalUpdates, &lastUpdate)
1800
1x
    if err != nil {
1801
        return nil, contextutils.WrapError(err, "failed to get background jobs status")
1802
    }
1803

1804
1x
    result := map[string]interface{}{
1805
1x
        "priorityUpdates": totalUpdates,
1806
1x
        "lastUpdate":      "N/A",
1807
1x
        "queueSize":       0, // Would need to track actual queue size
1808
1x
        "status":          "healthy",
1809
1x
    }
1810
1x

1811
1x
    if lastUpdate.Valid {
1812
        result["lastUpdate"] = lastUpdate.Time.Format(time.RFC3339)
1813
    }
1814

1815
1x
    if totalUpdates == 0 {
1816
1x
        result["status"] = "idle"
1817
1x
    }
1818

1819
1x
    span.SetAttributes(
1820
1x
        attribute.Int("priority_updates", totalUpdates),
1821
1x
        attribute.String("status", result["status"].(string)),
1822
1x
        attribute.Int("queue_size", result["queueSize"].(int)),
1823
1x
    )
1824
1x

1825
1x
    return result, nil
1826
}
1827

1828
// GetUserPriorityScoreDistribution returns priority score distribution for a specific user
1829
4x
func (s *LearningService) GetUserPriorityScoreDistribution(ctx context.Context, userID int) (result0 map[string]interface{}, err error) {
1830
4x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_priority_score_distribution",
1831
4x
        observability.AttributeUserID(userID),
1832
4x
    )
1833
4x
    defer func() {
1834
4x
        if err != nil {
1835
            span.RecordError(err, trace.WithStackTrace(true))
1836
            span.SetStatus(codes.Error, err.Error())
1837
        }
1838
4x
        span.End()
1839
    }()
1840

1841
4x
    query := `
1842
4x
        SELECT
1843
4x
            COUNT(CASE WHEN priority_score > 200 THEN 1 END) as high,
1844
4x
            COUNT(CASE WHEN priority_score BETWEEN 100 AND 200 THEN 1 END) as medium,
1845
4x
            COUNT(CASE WHEN priority_score < 100 THEN 1 END) as low,
1846
4x
            AVG(priority_score) as average
1847
4x
        FROM question_priority_scores
1848
4x
        WHERE user_id = $1 AND priority_score > 0
1849
4x
    `
1850
4x

1851
4x
    var high, medium, low int
1852
4x
    var average sql.NullFloat64
1853
4x

1854
4x
    err = s.db.QueryRowContext(ctx, query, userID).Scan(&high, &medium, &low, &average)
1855
4x
    if err != nil {
1856
        return nil, contextutils.WrapError(err, "failed to get user priority score distribution")
1857
    }
1858

1859
4x
    result := map[string]interface{}{
1860
4x
        "high":    high,
1861
4x
        "medium":  medium,
1862
4x
        "low":     low,
1863
4x
        "average": 0.0,
1864
4x
    }
1865
4x

1866
4x
    if average.Valid {
1867
1x
        result["average"] = average.Float64
1868
1x
    }
1869

1870
4x
    span.SetAttributes(
1871
4x
        attribute.Int("high_count", high),
1872
4x
        attribute.Int("medium_count", medium),
1873
4x
        attribute.Int("low_count", low),
1874
4x
        attribute.Float64("average_score", result["average"].(float64)),
1875
4x
    )
1876
4x

1877
4x
    return result, nil
1878
}
1879

1880
// GetUserHighPriorityQuestions returns the highest priority questions for a specific user
1881
5x
func (s *LearningService) GetUserHighPriorityQuestions(ctx context.Context, userID, limit int) (result0 []map[string]interface{}, err error) {
1882
5x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_high_priority_questions",
1883
5x
        observability.AttributeUserID(userID),
1884
5x
        attribute.Int("limit", limit),
1885
5x
    )
1886
5x
    defer func() {
1887
5x
        if err != nil {
1888
            span.RecordError(err, trace.WithStackTrace(true))
1889
            span.SetStatus(codes.Error, err.Error())
1890
        }
1891
5x
        span.End()
1892
    }()
1893

1894
5x
    query := `
1895
5x
        SELECT
1896
5x
            q.type as question_type,
1897
5x
            q.level,
1898
5x
            q.topic_category as topic,
1899
5x
            qps.priority_score
1900
5x
        FROM question_priority_scores qps
1901
5x
        JOIN questions q ON qps.question_id = q.id
1902
5x
        WHERE qps.user_id = $1 AND qps.priority_score > 200
1903
5x
        ORDER BY qps.priority_score DESC
1904
5x
        LIMIT $2
1905
5x
    `
1906
5x

1907
5x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
1908
5x
    if err != nil {
1909
        return nil, contextutils.WrapError(err, "failed to get user high priority questions")
1910
    }
1911
5x
    defer func() {
1912
5x
        if err := rows.Close(); err != nil {
1913
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1914
        }
1915
    }()
1916

1917
5x
    var questions []map[string]interface{}
1918
5x
    for rows.Next() {
1919
9x
        var questionType, level, topic sql.NullString
1920
9x
        var priorityScore float64
1921
9x

1922
9x
        err = rows.Scan(&questionType, &level, &topic, &priorityScore)
1923
9x
        if err != nil {
1924
            continue
1925
        }
1926

1927
9x
        question := map[string]interface{}{
1928
9x
            "question_type":  questionType.String,
1929
9x
            "level":          level.String,
1930
9x
            "topic":          topic.String,
1931
9x
            "priority_score": priorityScore,
1932
9x
        }
1933
9x
        questions = append(questions, question)
1934
    }
1935

1936
5x
    span.SetAttributes(attribute.Int("questions_count", len(questions)))
1937
5x
    return questions, nil
1938
}
1939

1940
// GetUserWeakAreas returns weak areas for a specific user
1941
7x
func (s *LearningService) GetUserWeakAreas(ctx context.Context, userID, limit int) (result0 []map[string]interface{}, err error) {
1942
7x
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_weak_areas",
1943
7x
        observability.AttributeUserID(userID),
1944
7x
        attribute.Int("limit", limit),
1945
7x
    )
1946
7x
    defer func() {
1947
7x
        if err != nil {
1948
1x
            span.RecordError(err, trace.WithStackTrace(true))
1949
1x
            span.SetStatus(codes.Error, err.Error())
1950
1x
        }
1951
7x
        span.End()
1952
    }()
1953

1954
7x
    query := `
1955
7x
        SELECT
1956
7x
            topic,
1957
7x
            total_attempts,
1958
7x
            correct_attempts
1959
7x
        FROM performance_metrics
1960
7x
        WHERE user_id = $1 AND total_attempts > 0
1961
7x
        ORDER BY (correct_attempts::float / total_attempts) ASC
1962
7x
        LIMIT $2
1963
7x
    `
1964
7x

1965
7x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
1966
7x
    if err != nil {
1967
1x
        return nil, contextutils.WrapError(err, "failed to get user weak areas")
1968
1x
    }
1969
6x
    defer func() {
1970
6x
        if err := rows.Close(); err != nil {
1971
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1972
        }
1973
    }()
1974

1975
6x
    var weakAreas []map[string]interface{}
1976
6x
    for rows.Next() {
1977
8x
        var topic sql.NullString
1978
8x
        var totalAttempts, correctAttempts int
1979
8x

1980
8x
        err = rows.Scan(&topic, &totalAttempts, &correctAttempts)
1981
8x
        if err != nil {
1982
            continue
1983
        }
1984

1985
8x
        area := map[string]interface{}{
1986
8x
            "topic":            topic.String,
1987
8x
            "total_attempts":   totalAttempts,
1988
8x
            "correct_attempts": correctAttempts,
1989
8x
        }
1990
8x
        weakAreas = append(weakAreas, area)
1991
    }
1992

1993
6x
    span.SetAttributes(attribute.Int("weak_areas_count", len(weakAreas)))
1994
6x
    return weakAreas, nil
1995
}
1996

1997
// Priority generation methods moved to worker
1998

1999
// GetHighPriorityTopics returns topics with high average priority scores for a user
2000
1x
func (s *LearningService) GetHighPriorityTopics(ctx context.Context, userID int) (result0 []string, err error) {
2001
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_high_priority_topics",
2002
1x
        observability.AttributeUserID(userID),
2003
1x
    )
2004
1x
    defer func() {
2005
1x
        if err != nil {
2006
            span.RecordError(err, trace.WithStackTrace(true))
2007
            span.SetStatus(codes.Error, err.Error())
2008
        }
2009
1x
        span.End()
2010
    }()
2011

2012
1x
    query := `
2013
1x
        SELECT q.topic_category, AVG(qps.priority_score) as avg_score
2014
1x
        FROM questions q
2015
1x
        JOIN user_questions uq ON q.id = uq.question_id
2016
1x
        JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
2017
1x
        WHERE uq.user_id = $1
2018
1x
        AND q.topic_category IS NOT NULL
2019
1x
        AND q.topic_category != ''
2020
1x
        GROUP BY q.topic_category
2021
1x
        HAVING AVG(qps.priority_score) >= 150.0
2022
1x
        ORDER BY avg_score DESC
2023
1x
        LIMIT 5
2024
1x
    `
2025
1x

2026
1x
    rows, err := s.db.QueryContext(ctx, query, userID)
2027
1x
    if err != nil {
2028
        return nil, contextutils.WrapError(err, "failed to get high priority topics")
2029
    }
2030
1x
    defer func() {
2031
1x
        if err := rows.Close(); err != nil {
2032
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2033
        }
2034
    }()
2035

2036
1x
    var topics []string
2037
1x
    for rows.Next() {
2038
        var topic string
2039
        var avgScore float64
2040
        if err := rows.Scan(&topic, &avgScore); err != nil {
2041
            continue
2042
        }
2043
        topics = append(topics, topic)
2044
    }
2045

2046
1x
    span.SetAttributes(attribute.Int("topics_count", len(topics)))
2047
1x
    // Ensure we always return a slice, not nil
2048
1x
    if topics == nil {
2049
1x
        topics = []string{}
2050
1x
    }
2051
1x
    return topics, nil
2052
}
2053

2054
// GetGapAnalysis identifies areas with poor user performance (knowledge gaps)
2055
1x
func (s *LearningService) GetGapAnalysis(ctx context.Context, userID int) (result0 map[string]interface{}, err error) {
2056
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_gap_analysis",
2057
1x
        observability.AttributeUserID(userID),
2058
1x
    )
2059
1x
    defer func() {
2060
1x
        if err != nil {
2061
            span.RecordError(err, trace.WithStackTrace(true))
2062
            span.SetStatus(codes.Error, err.Error())
2063
        }
2064
1x
        span.End()
2065
    }()
2066

2067
    // Query to find areas where user has poor performance (low accuracy)
2068
1x
    query := `
2069
1x
        SELECT
2070
1x
            pm.topic,
2071
1x
            COUNT(*) as total_questions,
2072
1x
            ROUND((pm.correct_attempts * 100.0 / pm.total_attempts), 2) as accuracy_percentage
2073
1x
        FROM performance_metrics pm
2074
1x
        WHERE pm.user_id = $1
2075
1x
        AND pm.total_attempts >= 3
2076
1x
        AND (pm.correct_attempts * 100.0 / pm.total_attempts) < 70.0
2077
1x
        GROUP BY pm.topic, pm.correct_attempts, pm.total_attempts
2078
1x
        ORDER BY accuracy_percentage ASC
2079
1x
        LIMIT 10
2080
1x
    `
2081
1x

2082
1x
    rows, err := s.db.QueryContext(ctx, query, userID)
2083
1x
    if err != nil {
2084
        return nil, contextutils.WrapError(err, "failed to get gap analysis")
2085
    }
2086
1x
    defer func() {
2087
1x
        if err := rows.Close(); err != nil {
2088
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2089
        }
2090
    }()
2091

2092
1x
    gaps := make(map[string]interface{})
2093
1x
    for rows.Next() {
2094
        var topic string
2095
        var totalQuestions int
2096
        var accuracyPercentage sql.NullFloat64
2097

2098
        if err := rows.Scan(&topic, &totalQuestions, &accuracyPercentage); err != nil {
2099
            continue
2100
        }
2101

2102
        gapInfo := map[string]interface{}{
2103
            "topic":               topic,
2104
            "total_questions":     totalQuestions,
2105
            "accuracy_percentage": 0.0,
2106
        }
2107

2108
        if accuracyPercentage.Valid {
2109
            gapInfo["accuracy_percentage"] = accuracyPercentage.Float64
2110
        }
2111

2112
        gaps[topic] = gapInfo
2113
    }
2114

2115
1x
    span.SetAttributes(attribute.Int("gaps_count", len(gaps)))
2116
1x
    return gaps, nil
2117
}
2118

2119
// GetPriorityDistribution returns the distribution of priority scores by topic for a user
2120
1x
func (s *LearningService) GetPriorityDistribution(ctx context.Context, userID int) (result0 map[string]int, err error) {
2121
1x
    ctx, span := observability.TraceLearningFunction(ctx, "get_priority_distribution",
2122
1x
        observability.AttributeUserID(userID),
2123
1x
    )
2124
1x
    defer func() {
2125
1x
        if err != nil {
2126
            span.RecordError(err, trace.WithStackTrace(true))
2127
            span.SetStatus(codes.Error, err.Error())
2128
        }
2129
1x
        span.End()
2130
    }()
2131

2132
    // Query to get priority score distribution by topic
2133
1x
    query := `
2134
1x
        SELECT q.topic_category, COUNT(*) as question_count
2135
1x
        FROM questions q
2136
1x
        JOIN user_questions uq ON q.id = uq.question_id
2137
1x
        JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
2138
1x
        WHERE uq.user_id = $1
2139
1x
        AND q.topic_category IS NOT NULL
2140
1x
        AND q.topic_category != ''
2141
1x
        GROUP BY q.topic_category
2142
1x
        ORDER BY question_count DESC
2143
1x
    `
2144
1x

2145
1x
    rows, err := s.db.QueryContext(ctx, query, userID)
2146
1x
    if err != nil {
2147
        return nil, contextutils.WrapError(err, "failed to get priority distribution")
2148
    }
2149
1x
    defer func() {
2150
1x
        if err := rows.Close(); err != nil {
2151
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2152
        }
2153
    }()
2154

2155
1x
    distribution := make(map[string]int)
2156
1x
    for rows.Next() {
2157
        var topic string
2158
        var count int
2159
        if err := rows.Scan(&topic, &count); err != nil {
2160
            continue
2161
        }
2162
        distribution[topic] = count
2163
    }
2164

2165
1x
    span.SetAttributes(attribute.Int("topics_count", len(distribution)))
2166
1x
    return distribution, nil
2167
}
2168

2169
// GetUserQuestionConfidenceLevel retrieves the confidence level for a specific question and user
2170
func (s *LearningService) GetUserQuestionConfidenceLevel(ctx context.Context, userID, questionID int) (result0 *int, err error) {
2171
    ctx, span := observability.TraceLearningFunction(ctx, "get_user_question_confidence_level",
2172
        observability.AttributeUserID(userID),
2173
        observability.AttributeQuestionID(questionID),
2174
    )
2175
    defer func() {
2176
        if err != nil {
2177
            span.RecordError(err, trace.WithStackTrace(true))
2178
            span.SetStatus(codes.Error, err.Error())
2179
        }
2180
        span.End()
2181
    }()
2182

2183
    query := `
2184
        SELECT confidence_level
2185
        FROM user_question_metadata
2186
        WHERE user_id = $1 AND question_id = $2
2187
    `
2188

2189
    var confidenceLevel sql.NullInt32
2190
    err = s.db.QueryRowContext(ctx, query, userID, questionID).Scan(&confidenceLevel)
2191
    if err != nil {
2192
        if err == sql.ErrNoRows {
2193
            // No confidence level recorded for this user-question pair
2194
            return nil, nil
2195
        }
2196
        return nil, contextutils.WrapError(err, "failed to get user question confidence level")
2197
    }
2198

2199
    if confidenceLevel.Valid {
2200
        level := int(confidenceLevel.Int32)
2201
        return &level, nil
2202
    }
2203

2204
    return nil, nil
2205
}
2206


			
quizapp internal services worker_service.go
50.0%
Statements
1/2
1
package services
2

3
import (
4
    "fmt"
5

6
    contextutils "quizapp/internal/utils"
7
)
8

9
// NoQuestionsAvailableError is returned when no suitable questions can be found for assignment.
10
type NoQuestionsAvailableError struct {
11
    Language       string
12
    Level          string
13
    CandidateIDs   []int
14
    CandidateCount int
15
    TotalMatching  int
16
}
17

18
2x
func (e *NoQuestionsAvailableError) Error() string {
19
2x
    return fmt.Sprintf("no questions available for assignment (language=%s level=%s candidate_count=%d total_matching=%d)", e.Language, e.Level, e.CandidateCount, e.TotalMatching)
20
2x
}
21

22
// Unwrap allows errors.Is(..., contextutils.ErrNoQuestionsAvailable) to work.
23
func (e *NoQuestionsAvailableError) Unwrap() error {
24
    return contextutils.ErrNoQuestionsAvailable
25
}
26


			
quizapp internal services worker_service.go
62.2%
Statements
84/135
1
package services
2

3
import (
4
    "context"
5
    "encoding/json"
6
    "errors"
7
    "fmt"
8
    "io"
9
    "net/http"
10
    "net/url"
11
    "strings"
12

13
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
14
    "go.opentelemetry.io/otel/attribute"
15
    "go.opentelemetry.io/otel/trace"
16

17
    "quizapp/internal/config"
18
    "quizapp/internal/models"
19
    "quizapp/internal/observability"
20
    contextutils "quizapp/internal/utils"
21
)
22

23
// ErrSignupsDisabled is returned when user registration is disabled by config
24
var ErrSignupsDisabled = errors.New("user registration is currently disabled")
25

26
// OAuth sentinel errors
27
var (
28
    ErrOAuthCodeAlreadyUsed  = errors.New("authorization code has already been used")
29
    ErrOAuthClientConfig     = errors.New("OAuth client configuration error")
30
    ErrOAuthInvalidRequest   = errors.New("invalid OAuth request")
31
    ErrOAuthUnauthorized     = errors.New("OAuth client is not authorized")
32
    ErrOAuthUnsupportedGrant = errors.New("unsupported OAuth grant type")
33
)
34

35
// OAuthService handles OAuth authentication flows
36
type OAuthService struct {
37
    config           *config.Config
38
    TokenEndpoint    string // for testing/mocking
39
    UserInfoEndpoint string // for testing/mocking
40
    logger           *observability.Logger
41
}
42

43
// NewOAuthServiceWithLogger creates a new OAuth service with logger
44
7x
func NewOAuthServiceWithLogger(cfg *config.Config, logger *observability.Logger) *OAuthService {
45
7x
    return &OAuthService{
46
7x
        config:           cfg,
47
7x
        TokenEndpoint:    "https://oauth2.googleapis.com/token",
48
7x
        UserInfoEndpoint: "https://www.googleapis.com/oauth2/v2/userinfo",
49
7x
        logger:           logger,
50
7x
    }
51
7x
}
52

53
// GoogleUserInfo represents the user information returned by Google OAuth
54
type GoogleUserInfo struct {
55
    ID            string `json:"id"`
56
    Email         string `json:"email"`
57
    Name          string `json:"name"`
58
    GivenName     string `json:"given_name"`
59
    FamilyName    string `json:"family_name"`
60
    Picture       string `json:"picture"`
61
    VerifiedEmail bool   `json:"verified_email"`
62
}
63

64
// GoogleTokenResponse represents the token response from Google OAuth
65
type GoogleTokenResponse struct {
66
    AccessToken  string `json:"access_token"`
67
    TokenType    string `json:"token_type"`
68
    ExpiresIn    int    `json:"expires_in"`
69
    RefreshToken string `json:"refresh_token,omitempty"`
70
    IDToken      string `json:"id_token,omitempty"`
71
}
72

73
// GetGoogleAuthURL generates the Google OAuth authorization URL
74
1x
func (s *OAuthService) GetGoogleAuthURL(ctx context.Context, state string) string {
75
1x
    _, span := observability.TraceOAuthFunction(ctx, "get_google_auth_url",
76
1x
        attribute.String("oauth.state", state),
77
1x
        attribute.String("oauth.client_id", s.config.GoogleOAuthClientID),
78
1x
        attribute.String("oauth.redirect_url", s.config.GoogleOAuthRedirectURL),
79
1x
    )
80
1x
    defer span.End()
81
1x

82
1x
    // Debug logging
83
1x
    if s.config.GoogleOAuthClientID == "" {
84
        if s.logger != nil {
85
            s.logger.Warn(ctx, "Google OAuth client ID is not set", map[string]interface{}{"env_var": "GOOGLE_OAUTH_CLIENT_ID"})
86
        }
87
    }
88
1x
    if s.config.GoogleOAuthRedirectURL == "" {
89
        if s.logger != nil {
90
            s.logger.Warn(ctx, "Google OAuth redirect URL is not set", map[string]interface{}{"env_var": "GOOGLE_OAUTH_REDIRECT_URL"})
91
        }
92
    }
93

94
1x
    params := url.Values{}
95
1x
    params.Set("client_id", s.config.GoogleOAuthClientID)
96
1x
    params.Set("redirect_uri", s.config.GoogleOAuthRedirectURL)
97
1x
    params.Set("response_type", "code")
98
1x
    params.Set("scope", "openid email profile")
99
1x
    params.Set("state", state)
100
1x
    params.Set("access_type", "offline")
101
1x
    params.Set("prompt", "consent")
102
1x

103
1x
    return fmt.Sprintf("https://accounts.google.com/o/oauth2/v2/auth?%s", params.Encode())
104
}
105

106
// ExchangeCodeForToken exchanges the authorization code for an access token
107
2x
func (s *OAuthService) ExchangeCodeForToken(ctx context.Context, code string) (result0 *GoogleTokenResponse, err error) {
108
2x
    ctx, span := observability.TraceOAuthFunction(ctx, "exchange_code_for_token",
109
2x
        attribute.String("oauth.code", code),
110
2x
        attribute.String("oauth.token_endpoint", s.TokenEndpoint),
111
2x
    )
112
2x
    defer observability.FinishSpan(span, &err)
113
2x

114
2x
    data := url.Values{}
115
2x
    data.Set("client_id", s.config.GoogleOAuthClientID)
116
2x
    data.Set("client_secret", s.config.GoogleOAuthClientSecret)
117
2x
    data.Set("code", code)
118
2x
    data.Set("grant_type", "authorization_code")
119
2x
    data.Set("redirect_uri", s.config.GoogleOAuthRedirectURL)
120
2x

121
2x
    tokenURL := s.TokenEndpoint
122
2x
    if tokenURL == "" {
123
        tokenURL = "https://oauth2.googleapis.com/token"
124
    }
125

126
2x
    req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
127
2x
    if err != nil {
128
        span.SetAttributes(attribute.String("error", err.Error()))
129
        return nil, contextutils.WrapError(err, "failed to create token request")
130
    }
131

132
2x
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
133
2x

134
2x
    // Use instrumented HTTP client for automatic tracing with explicit span options
135
2x
    client := &http.Client{
136
2x
        Timeout: config.OAuthHTTPTimeout,
137
2x
        Transport: otelhttp.NewTransport(http.DefaultTransport,
138
2x
            otelhttp.WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)),
139
2x
        ),
140
2x
    }
141
2x
    resp, err := client.Do(req.WithContext(ctx))
142
2x
    if err != nil {
143
        span.SetAttributes(attribute.String("error", err.Error()))
144
        return nil, contextutils.WrapError(err, "failed to exchange code for token")
145
    }
146
2x
    defer func() {
147
2x
        cerr := resp.Body.Close()
148
2x
        if cerr != nil {
149
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": cerr.Error()})
150
        }
151
    }()
152

153
2x
    span.SetAttributes(attribute.Int("http.status_code", resp.StatusCode))
154
2x

155
2x
    if resp.StatusCode != http.StatusOK {
156
        body, _ := io.ReadAll(resp.Body)
157

158
        // Try to parse the error response for better error messages
159
        var errorResp struct {
160
            Error            string `json:"error"`
161
            ErrorDescription string `json:"error_description"`
162
        }
163

164
        if json.Unmarshal(body, &errorResp) == nil {
165
            span.SetAttributes(
166
                attribute.String("oauth.error", errorResp.Error),
167
                attribute.String("oauth.error_description", errorResp.ErrorDescription),
168
            )
169
            switch errorResp.Error {
170
            case "invalid_grant":
171
                return nil, contextutils.WrapErrorf(ErrOAuthCodeAlreadyUsed, "please try signing in again")
172
            case "invalid_client":
173
                return nil, contextutils.WrapError(ErrOAuthClientConfig, "")
174
            case "invalid_request":
175
                return nil, contextutils.WrapError(ErrOAuthInvalidRequest, "")
176
            case "unauthorized_client":
177
                return nil, contextutils.WrapError(ErrOAuthUnauthorized, "")
178
            case "unsupported_grant_type":
179
                return nil, contextutils.WrapError(ErrOAuthUnsupportedGrant, "")
180
            default:
181
                return nil, contextutils.WrapErrorf(contextutils.ErrOAuthProviderError, "OAuth error: %s - %s", errorResp.Error, errorResp.ErrorDescription)
182
            }
183
        }
184

185
        return nil, contextutils.WrapErrorf(contextutils.ErrOAuthProviderError, "token exchange failed with status %d: %s", resp.StatusCode, string(body))
186
    }
187

188
2x
    var tokenResp GoogleTokenResponse
189
2x
    if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
190
        span.SetAttributes(attribute.String("error", err.Error()))
191
        return nil, contextutils.WrapError(err, "failed to decode token response")
192
    }
193

194
2x
    span.SetAttributes(
195
2x
        attribute.String("oauth.token_type", tokenResp.TokenType),
196
2x
        attribute.Int("oauth.expires_in", tokenResp.ExpiresIn),
197
2x
    )
198
2x

199
2x
    return &tokenResp, nil
200
}
201

202
// GetGoogleUserInfo retrieves user information from Google using the access token
203
2x
func (s *OAuthService) GetGoogleUserInfo(ctx context.Context, accessToken string) (result0 *GoogleUserInfo, err error) {
204
2x
    ctx, span := observability.TraceOAuthFunction(ctx, "get_google_user_info",
205
2x
        attribute.String("oauth.userinfo_endpoint", s.UserInfoEndpoint),
206
2x
    )
207
2x
    defer observability.FinishSpan(span, &err)
208
2x

209
2x
    userinfoURL := s.UserInfoEndpoint
210
2x
    if userinfoURL == "" {
211
        userinfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
212
    }
213

214
2x
    req, err := http.NewRequest("GET", userinfoURL, nil)
215
2x
    if err != nil {
216
        span.SetAttributes(attribute.String("error", err.Error()))
217
        return nil, contextutils.WrapError(err, "failed to create userinfo request")
218
    }
219

220
2x
    req.Header.Set("Authorization", "Bearer "+accessToken)
221
2x

222
2x
    // Use instrumented HTTP client for automatic tracing with explicit span options
223
2x
    client := &http.Client{
224
2x
        Timeout: config.OAuthHTTPTimeout,
225
2x
        Transport: otelhttp.NewTransport(http.DefaultTransport,
226
2x
            otelhttp.WithSpanOptions(trace.WithSpanKind(trace.SpanKindClient)),
227
2x
        ),
228
2x
    }
229
2x
    resp, err := client.Do(req.WithContext(ctx))
230
2x
    if err != nil {
231
        span.SetAttributes(attribute.String("error", err.Error()))
232
        return nil, contextutils.WrapError(err, "failed to get user info")
233
    }
234
2x
    defer func() {
235
2x
        cerr := resp.Body.Close()
236
2x
        if cerr != nil {
237
            s.logger.Warn(ctx, "Failed to close response body", map[string]interface{}{"error": cerr.Error()})
238
        }
239
    }()
240

241
2x
    span.SetAttributes(attribute.Int("http.status_code", resp.StatusCode))
242
2x

243
2x
    if resp.StatusCode != http.StatusOK {
244
        body, _ := io.ReadAll(resp.Body)
245
        span.SetAttributes(attribute.String("error", fmt.Sprintf("userinfo request failed with status %d: %s", resp.StatusCode, string(body))))
246
        return nil, contextutils.WrapErrorf(contextutils.ErrOAuthProviderError, "userinfo request failed with status %d: %s", resp.StatusCode, string(body))
247
    }
248

249
2x
    var userInfo GoogleUserInfo
250
2x
    if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
251
        span.SetAttributes(attribute.String("error", err.Error()))
252
        return nil, contextutils.WrapError(err, "failed to decode user info")
253
    }
254

255
2x
    span.SetAttributes(
256
2x
        attribute.String("user.email", userInfo.Email),
257
2x
        attribute.String("user.id", userInfo.ID),
258
2x
        attribute.Bool("user.verified_email", userInfo.VerifiedEmail),
259
2x
    )
260
2x

261
2x
    return &userInfo, nil
262
}
263

264
// AuthenticateGoogleUser handles the complete Google OAuth flow
265
2x
func (s *OAuthService) AuthenticateGoogleUser(ctx context.Context, code string, userService UserServiceInterface) (result0 *models.User, err error) {
266
2x
    ctx, span := observability.TraceOAuthFunction(ctx, "authenticate_google_user",
267
2x
        attribute.String("oauth.code", code),
268
2x
    )
269
2x
    defer observability.FinishSpan(span, &err)
270
2x

271
2x
    // Exchange code for token
272
2x
    tokenResp, err := s.ExchangeCodeForToken(ctx, code)
273
2x
    if err != nil {
274
        span.SetAttributes(attribute.String("error", err.Error()))
275
        return nil, contextutils.WrapError(err, "failed to exchange code for token")
276
    }
277

278
    // Get user info from Google
279
2x
    userInfo, err := s.GetGoogleUserInfo(ctx, tokenResp.AccessToken)
280
2x
    if err != nil {
281
        span.SetAttributes(attribute.String("error", err.Error()))
282
        return nil, contextutils.WrapError(err, "failed to get user info")
283
    }
284

285
2x
    span.SetAttributes(
286
2x
        attribute.String("user.email", userInfo.Email),
287
2x
        attribute.String("user.id", userInfo.ID),
288
2x
    )
289
2x

290
2x
    // Check if user exists by email
291
2x
    existingUser, err := userService.GetUserByEmail(ctx, userInfo.Email)
292
2x
    if err != nil {
293
        span.SetAttributes(attribute.String("error", err.Error()))
294
        return nil, contextutils.WrapError(err, "failed to check existing user")
295
    }
296

297
2x
    if existingUser != nil {
298
1x
        // User exists, return the user
299
1x
        span.SetAttributes(
300
1x
            attribute.Int("user.id", existingUser.ID),
301
1x
            attribute.String("auth.result", "existing_user"),
302
1x
        )
303
1x
        return existingUser, nil
304
1x
    }
305

306
    // Check if signups are disabled before creating new user
307
1x
    if s.config != nil && s.config.IsSignupDisabled() {
308
        // Check if OAuth signup is allowed via whitelist
309
        if !s.config.IsOAuthSignupAllowed(userInfo.Email) {
310
            span.SetAttributes(
311
                attribute.String("auth.result", "oauth_signup_blocked"),
312
                attribute.String("user.email", userInfo.Email),
313
            )
314
            return nil, ErrSignupsDisabled
315
        }
316
        // Allow OAuth signup for whitelisted email/domain
317
        span.SetAttributes(
318
            attribute.String("auth.result", "oauth_signup_allowed"),
319
            attribute.String("user.email", userInfo.Email),
320
        )
321
    }
322

323
    // User doesn't exist, create new user
324
    // Use email as username (we'll handle conflicts)
325
1x
    username := userInfo.Email
326
1x
    email := userInfo.Email
327
1x

328
1x
    // Check if username already exists, if so, append a number
329
1x
    counter := 1
330
1x
    for {
331
1x
        existingUser, err := userService.GetUserByUsername(ctx, username)
332
1x
        if err != nil {
333
            span.SetAttributes(attribute.String("error", err.Error()))
334
            return nil, contextutils.WrapError(err, "failed to check username availability")
335
        }
336
1x
        if existingUser == nil {
337
1x
            break
338
        }
339
        username = fmt.Sprintf("%s_%d", userInfo.Email, counter)
340
        counter++
341
    }
342

343
1x
    span.SetAttributes(
344
1x
        attribute.String("user.username", username),
345
1x
        attribute.String("user.email", email),
346
1x
        attribute.String("auth.result", "new_user"),
347
1x
    )
348
1x

349
1x
    // Create user with default settings
350
1x
    // Use email as username (we'll handle conflicts)
351
1x
    user, err := userService.CreateUserWithEmailAndTimezone(ctx, username, email, "UTC", "italian", "beginner")
352
1x
    if err != nil {
353
        span.SetAttributes(attribute.String("error", err.Error()))
354
        return nil, contextutils.WrapError(err, "failed to create user")
355
    }
356

357
1x
    span.SetAttributes(attribute.Int("user.id", user.ID))
358
1x

359
1x
    return user, nil
360
}
361


			
quizapp internal services worker_service.go
72.2%
Statements
866/1199
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "fmt"
8
    "math/rand"
9
    "strconv"
10
    "strings"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/models"
14
    "quizapp/internal/observability"
15
    contextutils "quizapp/internal/utils"
16

17
    "go.opentelemetry.io/otel/codes"
18
    "go.opentelemetry.io/otel/trace"
19
)
20

21
// QuestionServiceInterface defines the interface for question-related operations.
22
// This allows for easier mocking in tests.
23
type QuestionServiceInterface interface {
24
    SaveQuestion(ctx context.Context, question *models.Question) error
25
    AssignQuestionToUser(ctx context.Context, questionID, userID int) error
26
    GetQuestionByID(ctx context.Context, id int) (*models.Question, error)
27
    GetQuestionWithStats(ctx context.Context, id int) (*QuestionWithStats, error)
28
    GetQuestionsByFilter(ctx context.Context, userID int, language, level string, questionType models.QuestionType, limit int) ([]models.Question, error)
29
    GetNextQuestion(ctx context.Context, userID int, language, level string, qType models.QuestionType) (*QuestionWithStats, error)
30
    GetAdaptiveQuestionsForDaily(ctx context.Context, userID int, language, level string, limit int) ([]*QuestionWithStats, error)
31
    ReportQuestion(ctx context.Context, questionID, userID int, reportReason string) error
32
    GetQuestionStats(ctx context.Context) (map[string]interface{}, error)
33
    GetDetailedQuestionStats(ctx context.Context) (map[string]interface{}, error)
34
    GetRecentQuestionContentsForUser(ctx context.Context, userID, limit int) ([]string, error)
35
    GetReportedQuestions(ctx context.Context) ([]*ReportedQuestionWithUser, error)
36
    MarkQuestionAsFixed(ctx context.Context, questionID int) error
37
    UpdateQuestion(ctx context.Context, questionID int, content map[string]interface{}, correctAnswerIndex int, explanation string) error
38
    DeleteQuestion(ctx context.Context, questionID int) error
39
    GetUserQuestions(ctx context.Context, userID, limit int) ([]*models.Question, error)
40
    GetUserQuestionsWithStats(ctx context.Context, userID, limit int) ([]*QuestionWithStats, error)
41
    GetQuestionsPaginated(ctx context.Context, userID, page, pageSize int, search, typeFilter, statusFilter string) ([]*QuestionWithStats, int, error)
42
    GetAllQuestionsPaginated(ctx context.Context, page, pageSize int, search, typeFilter, statusFilter, languageFilter, levelFilter string, userID *int) ([]*QuestionWithStats, int, error)
43
    GetReportedQuestionsPaginated(ctx context.Context, page, pageSize int, search, typeFilter, languageFilter, levelFilter string) ([]*QuestionWithStats, int, error)
44
    GetReportedQuestionsStats(ctx context.Context) (map[string]interface{}, error)
45
    GetUserQuestionCount(ctx context.Context, userID int) (int, error)
46
    GetUserResponseCount(ctx context.Context, userID int) (int, error)
47
    GetRandomGlobalQuestionForUser(ctx context.Context, userID int, language, level string, qType models.QuestionType) (*QuestionWithStats, error)
48
    GetUsersForQuestion(ctx context.Context, questionID int) ([]*models.User, int, error)
49
    AssignUsersToQuestion(ctx context.Context, questionID int, userIDs []int) error
50
    UnassignUsersFromQuestion(ctx context.Context, questionID int, userIDs []int) error
51
    DB() *sql.DB
52
}
53

54
// QuestionService provides methods for question management.
55
type QuestionService struct {
56
    db              *sql.DB
57
    learningService *LearningService
58
    logger          *observability.Logger
59
    cfg             *config.Config
60
}
61

62
// Shared query constants to eliminate duplication
63
const (
64
    // questionSelectFields contains all question fields for SELECT queries
65
    questionSelectFields = `id, type, language, level, difficulty_score, content, correct_answer, explanation, created_at, status, topic_category, grammar_focus, vocabulary_domain, scenario, style_modifier, difficulty_modifier, time_context`
66
)
67

68
// scanQuestionFromRow scans a database row into a models.Question struct
69
14x
func (s *QuestionService) scanQuestionFromRow(row *sql.Row) (result0 *models.Question, err error) {
70
14x
    question := &models.Question{}
71
14x
    var contentJSON string
72
14x
    var topicCategory sql.NullString
73
14x
    var grammarFocus sql.NullString
74
14x
    var vocabularyDomain sql.NullString
75
14x
    var scenario sql.NullString
76
14x
    var styleModifier sql.NullString
77
14x
    var difficultyModifier sql.NullString
78
14x
    var timeContext sql.NullString
79
14x

80
14x
    err = row.Scan(
81
14x
        &question.ID,
82
14x
        &question.Type,
83
14x
        &question.Language,
84
14x
        &question.Level,
85
14x
        &question.DifficultyScore,
86
14x
        &contentJSON,
87
14x
        &question.CorrectAnswer,
88
14x
        &question.Explanation,
89
14x
        &question.CreatedAt,
90
14x
        &question.Status,
91
14x
        &topicCategory,
92
14x
        &grammarFocus,
93
14x
        &vocabularyDomain,
94
14x
        &scenario,
95
14x
        &styleModifier,
96
14x
        &difficultyModifier,
97
14x
        &timeContext,
98
14x
    )
99
14x
    if err != nil {
100
1x
        return nil, err
101
1x
    }
102

103
    // Set optional string fields if they have values
104
13x
    if topicCategory.Valid {
105
13x
        question.TopicCategory = topicCategory.String
106
13x
    }
107
13x
    if grammarFocus.Valid {
108
13x
        question.GrammarFocus = grammarFocus.String
109
13x
    }
110
13x
    if vocabularyDomain.Valid {
111
13x
        question.VocabularyDomain = vocabularyDomain.String
112
13x
    }
113
13x
    if scenario.Valid {
114
13x
        question.Scenario = scenario.String
115
13x
    }
116
13x
    if styleModifier.Valid {
117
13x
        question.StyleModifier = styleModifier.String
118
13x
    }
119
13x
    if difficultyModifier.Valid {
120
13x
        question.DifficultyModifier = difficultyModifier.String
121
13x
    }
122
13x
    if timeContext.Valid {
123
13x
        question.TimeContext = timeContext.String
124
13x
    }
125

126
13x
    if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
127
        return nil, err
128
    }
129

130
13x
    return question, nil
131
}
132

133
// scanQuestionFromRows scans a database rows into a models.Question struct
134
13x
func (s *QuestionService) scanQuestionFromRows(rows *sql.Rows) (result0 *models.Question, err error) {
135
13x
    question := &models.Question{}
136
13x
    var contentJSON string
137
13x
    var topicCategory sql.NullString
138
13x
    var grammarFocus sql.NullString
139
13x
    var vocabularyDomain sql.NullString
140
13x
    var scenario sql.NullString
141
13x
    var styleModifier sql.NullString
142
13x
    var difficultyModifier sql.NullString
143
13x
    var timeContext sql.NullString
144
13x

145
13x
    err = rows.Scan(
146
13x
        &question.ID,
147
13x
        &question.Type,
148
13x
        &question.Language,
149
13x
        &question.Level,
150
13x
        &question.DifficultyScore,
151
13x
        &contentJSON,
152
13x
        &question.CorrectAnswer,
153
13x
        &question.Explanation,
154
13x
        &question.CreatedAt,
155
13x
        &question.Status,
156
13x
        &topicCategory,
157
13x
        &grammarFocus,
158
13x
        &vocabularyDomain,
159
13x
        &scenario,
160
13x
        &styleModifier,
161
13x
        &difficultyModifier,
162
13x
        &timeContext,
163
13x
    )
164
13x
    if err != nil {
165
        return nil, err
166
    }
167

168
    // Set optional string fields if they have values
169
13x
    if topicCategory.Valid {
170
13x
        question.TopicCategory = topicCategory.String
171
13x
    }
172
13x
    if grammarFocus.Valid {
173
13x
        question.GrammarFocus = grammarFocus.String
174
13x
    }
175
13x
    if vocabularyDomain.Valid {
176
13x
        question.VocabularyDomain = vocabularyDomain.String
177
13x
    }
178
13x
    if scenario.Valid {
179
13x
        question.Scenario = scenario.String
180
13x
    }
181
13x
    if styleModifier.Valid {
182
13x
        question.StyleModifier = styleModifier.String
183
13x
    }
184
13x
    if difficultyModifier.Valid {
185
13x
        question.DifficultyModifier = difficultyModifier.String
186
13x
    }
187
13x
    if timeContext.Valid {
188
13x
        question.TimeContext = timeContext.String
189
13x
    }
190

191
13x
    if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
192
        return nil, err
193
    }
194

195
13x
    return question, nil
196
}
197

198
// scanQuestionBasicFromRows scans a database rows into a models.Question struct (basic fields only)
199
6x
func (s *QuestionService) scanQuestionBasicFromRows(rows *sql.Rows) (result0 *models.Question, err error) {
200
6x
    question := &models.Question{}
201
6x
    var contentJSON string
202
6x

203
6x
    err = rows.Scan(
204
6x
        &question.ID,
205
6x
        &question.Type,
206
6x
        &question.Language,
207
6x
        &question.Level,
208
6x
        &question.DifficultyScore,
209
6x
        &contentJSON,
210
6x
        &question.CorrectAnswer,
211
6x
        &question.Explanation,
212
6x
        &question.CreatedAt,
213
6x
        &question.Status,
214
6x
    )
215
6x
    if err != nil {
216
        return nil, err
217
    }
218

219
6x
    if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
220
        return nil, err
221
    }
222

223
6x
    return question, nil
224
}
225

226
// scanQuestionWithStatsFromRows scans a database rows into a QuestionWithStats struct
227
66x
func (s *QuestionService) scanQuestionWithStatsFromRows(rows *sql.Rows) (result0 *QuestionWithStats, err error) {
228
66x
    questionWithStats := &QuestionWithStats{
229
66x
        Question: &models.Question{},
230
66x
    }
231
66x
    var contentJSON string
232
66x

233
66x
    err = rows.Scan(
234
66x
        &questionWithStats.ID,
235
66x
        &questionWithStats.Type,
236
66x
        &questionWithStats.Language,
237
66x
        &questionWithStats.Level,
238
66x
        &questionWithStats.DifficultyScore,
239
66x
        &contentJSON,
240
66x
        &questionWithStats.CorrectAnswer,
241
66x
        &questionWithStats.Explanation,
242
66x
        &questionWithStats.CreatedAt,
243
66x
        &questionWithStats.Status,
244
66x
        &questionWithStats.CorrectCount,
245
66x
        &questionWithStats.IncorrectCount,
246
66x
        &questionWithStats.TotalResponses,
247
66x
        &questionWithStats.UserCount,
248
66x
    )
249
66x
    if err != nil {
250
        return nil, err
251
    }
252

253
66x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
254
        return nil, err
255
    }
256

257
66x
    return questionWithStats, nil
258
}
259

260
// scanQuestionWithStatsAndAllFieldsFromRows scans a database rows into a QuestionWithStats struct (with all fields)
261
67x
func (s *QuestionService) scanQuestionWithStatsAndAllFieldsFromRows(rows *sql.Rows) (result0 *QuestionWithStats, err error) {
262
67x
    questionWithStats := &QuestionWithStats{
263
67x
        Question: &models.Question{},
264
67x
    }
265
67x
    var contentJSON string
266
67x
    var topicCategory sql.NullString
267
67x
    var grammarFocus sql.NullString
268
67x
    var vocabularyDomain sql.NullString
269
67x
    var scenario sql.NullString
270
67x
    var styleModifier sql.NullString
271
67x
    var difficultyModifier sql.NullString
272
67x
    var timeContext sql.NullString
273
67x

274
67x
    err = rows.Scan(
275
67x
        &questionWithStats.ID,
276
67x
        &questionWithStats.Type,
277
67x
        &questionWithStats.Language,
278
67x
        &questionWithStats.Level,
279
67x
        &questionWithStats.DifficultyScore,
280
67x
        &contentJSON,
281
67x
        &questionWithStats.CorrectAnswer,
282
67x
        &questionWithStats.Explanation,
283
67x
        &questionWithStats.CreatedAt,
284
67x
        &questionWithStats.Status,
285
67x
        &topicCategory,
286
67x
        &grammarFocus,
287
67x
        &vocabularyDomain,
288
67x
        &scenario,
289
67x
        &styleModifier,
290
67x
        &difficultyModifier,
291
67x
        &timeContext,
292
67x
        &questionWithStats.CorrectCount,
293
67x
        &questionWithStats.IncorrectCount,
294
67x
        &questionWithStats.TotalResponses,
295
67x
        &questionWithStats.UserCount,
296
67x
    )
297
67x
    if err != nil {
298
        return nil, err
299
    }
300

301
    // Set optional string fields if they have values
302
67x
    if topicCategory.Valid {
303
67x
        questionWithStats.TopicCategory = topicCategory.String
304
67x
    }
305
67x
    if grammarFocus.Valid {
306
67x
        questionWithStats.GrammarFocus = grammarFocus.String
307
67x
    }
308
67x
    if vocabularyDomain.Valid {
309
67x
        questionWithStats.VocabularyDomain = vocabularyDomain.String
310
67x
    }
311
67x
    if scenario.Valid {
312
67x
        questionWithStats.Scenario = scenario.String
313
67x
    }
314
67x
    if styleModifier.Valid {
315
67x
        questionWithStats.StyleModifier = styleModifier.String
316
67x
    }
317
67x
    if difficultyModifier.Valid {
318
67x
        questionWithStats.DifficultyModifier = difficultyModifier.String
319
67x
    }
320
67x
    if timeContext.Valid {
321
67x
        questionWithStats.TimeContext = timeContext.String
322
67x
    }
323

324
67x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
325
        return nil, err
326
    }
327

328
67x
    return questionWithStats, nil
329
}
330

331
// scanQuestionWithPriorityAndStatsFromRows scans a database rows into a QuestionWithStats struct (with priority and stats)
332
3431x
func (s *QuestionService) scanQuestionWithPriorityAndStatsFromRows(rows *sql.Rows) (result0 *QuestionWithStats, err error) {
333
3431x
    questionWithStats := &QuestionWithStats{
334
3431x
        Question: &models.Question{},
335
3431x
    }
336
3431x
    var contentJSON string
337
3431x
    var priorityScore float64
338
3431x
    var timesAnswered int
339
3431x
    var lastAnsweredAt sql.NullTime
340
3431x
    var confidenceLevel sql.NullInt32
341
3431x
    var topicCategory sql.NullString
342
3431x
    var grammarFocus sql.NullString
343
3431x
    var vocabularyDomain sql.NullString
344
3431x
    var scenario sql.NullString
345
3431x
    var styleModifier sql.NullString
346
3431x
    var difficultyModifier sql.NullString
347
3431x
    var timeContext sql.NullString
348
3431x

349
3431x
    err = rows.Scan(
350
3431x
        &questionWithStats.ID,
351
3431x
        &questionWithStats.Type,
352
3431x
        &questionWithStats.Language,
353
3431x
        &questionWithStats.Level,
354
3431x
        &questionWithStats.DifficultyScore,
355
3431x
        &contentJSON,
356
3431x
        &questionWithStats.CorrectAnswer,
357
3431x
        &questionWithStats.Explanation,
358
3431x
        &questionWithStats.CreatedAt,
359
3431x
        &questionWithStats.Status,
360
3431x
        &topicCategory,
361
3431x
        &grammarFocus,
362
3431x
        &vocabularyDomain,
363
3431x
        &scenario,
364
3431x
        &styleModifier,
365
3431x
        &difficultyModifier,
366
3431x
        &timeContext,
367
3431x
        &priorityScore,
368
3431x
        &timesAnswered,
369
3431x
        &lastAnsweredAt,
370
3431x
        &questionWithStats.CorrectCount,
371
3431x
        &questionWithStats.IncorrectCount,
372
3431x
        &questionWithStats.TotalResponses,
373
3431x
        &confidenceLevel,
374
3431x
    )
375
3431x
    if err != nil {
376
        return nil, err
377
    }
378

379
    // Set optional string fields if they have values
380
3431x
    if topicCategory.Valid {
381
3391x
        questionWithStats.TopicCategory = topicCategory.String
382
3391x
    }
383
3431x
    if grammarFocus.Valid {
384
3391x
        questionWithStats.GrammarFocus = grammarFocus.String
385
3391x
    }
386
3431x
    if vocabularyDomain.Valid {
387
3391x
        questionWithStats.VocabularyDomain = vocabularyDomain.String
388
3391x
    }
389
3431x
    if scenario.Valid {
390
3391x
        questionWithStats.Scenario = scenario.String
391
3391x
    }
392
3431x
    if styleModifier.Valid {
393
3391x
        questionWithStats.StyleModifier = styleModifier.String
394
3391x
    }
395
3431x
    if difficultyModifier.Valid {
396
3391x
        questionWithStats.DifficultyModifier = difficultyModifier.String
397
3391x
    }
398
3431x
    if timeContext.Valid {
399
3391x
        questionWithStats.TimeContext = timeContext.String
400
3391x
    }
401

402
3431x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
403
        return nil, err
404
    }
405

406
    // Set confidence level if it exists
407
3431x
    if confidenceLevel.Valid {
408
        level := int(confidenceLevel.Int32)
409
        questionWithStats.ConfidenceLevel = &level
410
    }
411

412
    // Populate per-user times answered from the scanned value
413
3431x
    questionWithStats.TimesAnswered = timesAnswered
414
3431x

415
3431x
    return questionWithStats, nil
416
}
417

418
// scanQuestionWithStatsAndReportersFromRows scans a database rows into a QuestionWithStats struct (with reporter information)
419
11x
func (s *QuestionService) scanQuestionWithStatsAndReportersFromRows(rows *sql.Rows) (result0 *QuestionWithStats, err error) {
420
11x
    questionWithStats := &QuestionWithStats{
421
11x
        Question: &models.Question{},
422
11x
    }
423
11x
    var contentJSON string
424
11x
    var reporters sql.NullString
425
11x
    var reportReasons sql.NullString
426
11x
    var topicCategory sql.NullString
427
11x
    var grammarFocus sql.NullString
428
11x
    var vocabularyDomain sql.NullString
429
11x
    var scenario sql.NullString
430
11x
    var styleModifier sql.NullString
431
11x
    var difficultyModifier sql.NullString
432
11x
    var timeContext sql.NullString
433
11x

434
11x
    err = rows.Scan(
435
11x
        &questionWithStats.ID,
436
11x
        &questionWithStats.Type,
437
11x
        &questionWithStats.Language,
438
11x
        &questionWithStats.Level,
439
11x
        &questionWithStats.DifficultyScore,
440
11x
        &contentJSON,
441
11x
        &questionWithStats.CorrectAnswer,
442
11x
        &questionWithStats.Explanation,
443
11x
        &questionWithStats.CreatedAt,
444
11x
        &questionWithStats.Status,
445
11x
        &topicCategory,
446
11x
        &grammarFocus,
447
11x
        &vocabularyDomain,
448
11x
        &scenario,
449
11x
        &styleModifier,
450
11x
        &difficultyModifier,
451
11x
        &timeContext,
452
11x
        &questionWithStats.CorrectCount,
453
11x
        &questionWithStats.IncorrectCount,
454
11x
        &questionWithStats.TotalResponses,
455
11x
        &reporters,
456
11x
        &reportReasons,
457
11x
    )
458
11x
    if err != nil {
459
        return nil, err
460
    }
461

462
    // Set optional string fields if they have values
463
11x
    if topicCategory.Valid {
464
11x
        questionWithStats.TopicCategory = topicCategory.String
465
11x
    }
466
11x
    if grammarFocus.Valid {
467
11x
        questionWithStats.GrammarFocus = grammarFocus.String
468
11x
    }
469
11x
    if vocabularyDomain.Valid {
470
11x
        questionWithStats.VocabularyDomain = vocabularyDomain.String
471
11x
    }
472
11x
    if scenario.Valid {
473
11x
        questionWithStats.Scenario = scenario.String
474
11x
    }
475
11x
    if styleModifier.Valid {
476
11x
        questionWithStats.StyleModifier = styleModifier.String
477
11x
    }
478
11x
    if difficultyModifier.Valid {
479
11x
        questionWithStats.DifficultyModifier = difficultyModifier.String
480
11x
    }
481
11x
    if timeContext.Valid {
482
11x
        questionWithStats.TimeContext = timeContext.String
483
11x
    }
484

485
11x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
486
        return nil, err
487
    }
488

489
    // Store reporter information
490
11x
    if reporters.Valid && reporters.String != "" {
491
11x
        questionWithStats.Reporters = reporters.String
492
11x
    }
493

494
    // Store report reasons information
495
11x
    if reportReasons.Valid && reportReasons.String != "" {
496
11x
        questionWithStats.ReportReasons = reportReasons.String
497
11x
    }
498

499
11x
    return questionWithStats, nil
500
}
501

502
// getQuestionByQuery is a shared method for getting a question by any query
503
14x
func (s *QuestionService) getQuestionByQuery(ctx context.Context, query string, args ...interface{}) (result0 *models.Question, err error) {
504
14x
    row := s.db.QueryRowContext(ctx, query, args...)
505
14x
    var question *models.Question
506
14x
    question, err = s.scanQuestionFromRow(row)
507
14x
    if err != nil {
508
1x
        if errors.Is(err, sql.ErrNoRows) {
509
1x
            return nil, sql.ErrNoRows // Propagate sql.ErrNoRows for not found
510
1x
        }
511
        return nil, err
512
    }
513
13x
    return question, nil
514
}
515

516
// NewQuestionServiceWithLogger creates a new QuestionService instance with logger
517
70x
func NewQuestionServiceWithLogger(db *sql.DB, learningService *LearningService, cfg *config.Config, logger *observability.Logger) *QuestionService {
518
70x
    if db == nil {
519
4x
        panic("database connection cannot be nil")
520
    }
521
66x
    if logger == nil {
522
        panic("logger cannot be nil")
523
    }
524

525
66x
    return &QuestionService{
526
66x
        db:              db,
527
66x
        learningService: learningService,
528
66x
        logger:          logger,
529
66x
        cfg:             cfg,
530
66x
    }
531
}
532

533
// getDailyRepeatAvoidDays returns the configured number of days to avoid repeating
534
// questions in daily assignments. Defaults to 7 when not configured or invalid.
535
361x
func (s *QuestionService) getDailyRepeatAvoidDays() int {
536
361x
    if s.cfg != nil {
537
361x
        if days := s.cfg.Server.DailyRepeatAvoidDays; days > 0 {
538
361x
            return days
539
361x
        }
540
    }
541
    return 7
542
}
543

544
// SaveQuestion saves a question to the database
545
189x
func (s *QuestionService) SaveQuestion(ctx context.Context, question *models.Question) (err error) {
546
189x
    ctx, span := observability.TraceQuestionFunction(ctx, "save_question", observability.AttributeQuestion(question))
547
189x
    defer func() {
548
189x
        if err != nil {
549
            span.RecordError(err, trace.WithStackTrace(true))
550
            span.SetStatus(codes.Error, err.Error())
551
        }
552
189x
        span.End()
553
    }()
554
189x
    var contentJSON []byte
555
189x
    contentJSONStr, err := question.MarshalContentToJSON()
556
189x
    if err != nil {
557
        return contextutils.WrapError(err, "failed to marshal question content")
558
    }
559
189x
    contentJSON = []byte(contentJSONStr)
560
189x
    if err != nil {
561
        return contextutils.WrapError(err, "failed to marshal question content")
562
    }
563

564
189x
    if question.Status == "" {
565
7x
        question.Status = models.QuestionStatusActive
566
7x
    }
567

568
189x
    query := `
569
189x
        INSERT INTO questions (type, language, level, difficulty_score, content, correct_answer, explanation, status, topic_category, grammar_focus, vocabulary_domain, scenario, style_modifier, difficulty_modifier, time_context)
570
189x
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id
571
189x
    `
572
189x

573
189x
    var id int
574
189x
    err = s.db.QueryRowContext(ctx, query,
575
189x
        question.Type,
576
189x
        question.Language,
577
189x
        question.Level,
578
189x
        question.DifficultyScore,
579
189x
        string(contentJSON),
580
189x
        question.CorrectAnswer,
581
189x
        question.Explanation,
582
189x
        question.Status,
583
189x
        question.TopicCategory,
584
189x
        question.GrammarFocus,
585
189x
        question.VocabularyDomain,
586
189x
        question.Scenario,
587
189x
        question.StyleModifier,
588
189x
        question.DifficultyModifier,
589
189x
        question.TimeContext,
590
189x
    ).Scan(&id)
591
189x
    if err != nil {
592
        return contextutils.WrapError(err, "failed to save question to database")
593
    }
594

595
189x
    question.ID = id
596
189x
    return nil
597
}
598

599
// AssignQuestionToUser assigns a question to a user
600
195x
func (s *QuestionService) AssignQuestionToUser(ctx context.Context, questionID, userID int) (err error) {
601
195x
    ctx, span := observability.TraceQuestionFunction(ctx, "assign_question_to_user", observability.AttributeQuestionID(questionID), observability.AttributeUserID(userID))
602
195x
    defer func() {
603
195x
        if err != nil {
604
            span.RecordError(err, trace.WithStackTrace(true))
605
            span.SetStatus(codes.Error, err.Error())
606
        }
607
195x
        span.End()
608
    }()
609
195x
    query := `
610
195x
        INSERT INTO user_questions (user_id, question_id)
611
195x
        VALUES ($1, $2)
612
195x
        ON CONFLICT (user_id, question_id) DO NOTHING
613
195x
    `
614
195x
    _, err = s.db.ExecContext(ctx, query, userID, questionID)
615
195x
    return contextutils.WrapError(err, "failed to assign question to user")
616
}
617

618
// GetQuestionByID retrieves a question by its ID
619
14x
func (s *QuestionService) GetQuestionByID(ctx context.Context, id int) (result0 *models.Question, err error) {
620
14x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_question_by_id", observability.AttributeQuestionID(id))
621
14x
    defer func() {
622
14x
        if err != nil {
623
1x
            span.RecordError(err, trace.WithStackTrace(true))
624
1x
            span.SetStatus(codes.Error, err.Error())
625
1x
        }
626
14x
        span.End()
627
    }()
628
14x
    query := fmt.Sprintf("SELECT %s FROM questions WHERE id = $1", questionSelectFields)
629
14x
    return s.getQuestionByQuery(ctx, query, id)
630
}
631

632
// GetQuestionWithStats retrieves a question by its ID with response statistics
633
2x
func (s *QuestionService) GetQuestionWithStats(ctx context.Context, id int) (result0 *QuestionWithStats, err error) {
634
2x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_question_with_stats", observability.AttributeQuestionID(id))
635
2x
    defer func() {
636
2x
        if err != nil {
637
            span.RecordError(err, trace.WithStackTrace(true))
638
            span.SetStatus(codes.Error, err.Error())
639
        }
640
2x
        span.End()
641
    }()
642
2x
    query := `
643
2x
        SELECT
644
2x
            q.id, q.type, q.language, q.level, q.difficulty_score,
645
2x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
646
2x
            q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
647
2x
            COALESCE(SUM(CASE WHEN ur.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
648
2x
            COALESCE(SUM(CASE WHEN ur.is_correct = false THEN 1 ELSE 0 END), 0) as incorrect_count,
649
2x
            COALESCE(COUNT(ur.id), 0) as total_responses
650
2x
        FROM questions q
651
2x
        LEFT JOIN user_responses ur ON q.id = ur.question_id
652
2x
        WHERE q.id = $1
653
2x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score,
654
2x
                 q.content, q.correct_answer, q.explanation, q.created_at, q.status,
655
2x
                 q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context
656
2x
    `
657
2x

658
2x
    q := &models.Question{}
659
2x
    stats := &QuestionWithStats{Question: q}
660
2x

661
2x
    var contentJSON string
662
2x
    err = s.db.QueryRowContext(ctx, query, id).Scan(
663
2x
        &q.ID, &q.Type, &q.Language, &q.Level, &q.DifficultyScore,
664
2x
        &contentJSON, &q.CorrectAnswer, &q.Explanation, &q.CreatedAt, &q.Status,
665
2x
        &q.TopicCategory, &q.GrammarFocus, &q.VocabularyDomain, &q.Scenario, &q.StyleModifier, &q.DifficultyModifier, &q.TimeContext,
666
2x
        &stats.CorrectCount, &stats.IncorrectCount, &stats.TotalResponses,
667
2x
    )
668
2x
    if err != nil {
669
        if errors.Is(err, sql.ErrNoRows) {
670
            return nil, contextutils.ErrQuestionNotFound
671
        }
672
        return nil, contextutils.WrapError(err, "failed to get question with stats")
673
    }
674

675
    // Parse JSON content
676
2x
    if err := q.UnmarshalContentFromJSON(contentJSON); err != nil {
677
        return nil, contextutils.WrapError(err, "failed to unmarshal question content")
678
    }
679

680
2x
    return stats, nil
681
}
682

683
// GetQuestionsByFilter retrieves questions matching the specified criteria
684
8x
func (s *QuestionService) GetQuestionsByFilter(ctx context.Context, userID int, language, level string, questionType models.QuestionType, limit int) (result0 []models.Question, err error) {
685
8x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_questions_by_filter", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(questionType))
686
8x
    defer func() {
687
8x
        if err != nil {
688
            span.RecordError(err, trace.WithStackTrace(true))
689
            span.SetStatus(codes.Error, err.Error())
690
        }
691
8x
        span.End()
692
    }()
693
8x
    var query string
694
8x
    var args []interface{}
695
8x

696
8x
    if questionType == "" {
697
3x
        // Don't filter by type if questionType is empty
698
3x
        query = `
699
3x
            SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status
700
3x
            FROM questions q
701
3x
            JOIN user_questions uq ON q.id = uq.question_id
702
3x
            WHERE uq.user_id = $1 AND q.language = $2 AND q.level = $3 AND q.status = $4
703
3x
            ORDER BY RANDOM()
704
3x
            LIMIT $5
705
3x
        `
706
3x
        args = []interface{}{userID, language, level, models.QuestionStatusActive, limit}
707
3x
    } else {
708
5x
        // Filter by specific type
709
5x
        query = `
710
5x
            SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status
711
5x
            FROM questions q
712
5x
            JOIN user_questions uq ON q.id = uq.question_id
713
5x
            WHERE uq.user_id = $1 AND q.language = $2 AND q.level = $3 AND q.type = $4 AND q.status = $5
714
5x
            ORDER BY RANDOM()
715
5x
            LIMIT $6
716
5x
        `
717
5x
        args = []interface{}{userID, language, level, questionType, models.QuestionStatusActive, limit}
718
5x
    }
719

720
8x
    rows, err := s.db.QueryContext(ctx, query, args...)
721
8x
    if err != nil {
722
        return nil, contextutils.WrapError(err, "failed to query questions by filter")
723
    }
724
8x
    defer func() {
725
8x
        if err := rows.Close(); err != nil {
726
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
727
        }
728
    }()
729

730
8x
    var questions []models.Question
731
8x
    for rows.Next() {
732
6x
        question, err := s.scanQuestionBasicFromRows(rows)
733
6x
        if err != nil {
734
            return nil, contextutils.WrapError(err, "failed to scan question from rows")
735
        }
736
6x
        questions = append(questions, *question)
737
    }
738

739
8x
    return questions, nil
740
}
741

742
// ReportedQuestionWithUser represents a reported question with user information
743
type ReportedQuestionWithUser struct {
744
    *models.Question
745
    ReportedByUsername string `json:"reported_by_username"`
746
    TotalResponses     int    `json:"total_responses"`
747
}
748

749
// GetReportedQuestions retrieves all questions that have been reported as problematic
750
1x
func (s *QuestionService) GetReportedQuestions(ctx context.Context) (result0 []*ReportedQuestionWithUser, err error) {
751
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_reported_questions")
752
1x
    defer func() {
753
1x
        if err != nil {
754
            span.RecordError(err, trace.WithStackTrace(true))
755
            span.SetStatus(codes.Error, err.Error())
756
        }
757
1x
        span.End()
758
    }()
759
1x
    query := `
760
1x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status, u.username,
761
1x
               COALESCE(COUNT(ur.id), 0) as total_responses
762
1x
        FROM questions q
763
1x
        LEFT JOIN user_questions uq ON q.id = uq.question_id
764
1x
        LEFT JOIN users u ON uq.user_id = u.id
765
1x
        LEFT JOIN user_responses ur ON q.id = ur.question_id
766
1x
        WHERE q.status = $1
767
1x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status, u.username
768
1x
        ORDER BY q.created_at DESC
769
1x
    `
770
1x

771
1x
    var rows *sql.Rows
772
1x
    rows, err = s.db.QueryContext(ctx, query, models.QuestionStatusReported)
773
1x
    if err != nil {
774
        return nil, contextutils.WrapError(err, "failed to query reported questions")
775
    }
776
1x
    defer func() {
777
1x
        if err := rows.Close(); err != nil {
778
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
779
        }
780
    }()
781

782
1x
    var questions []*ReportedQuestionWithUser
783
1x
    for rows.Next() {
784
2x
        var question models.Question
785
2x
        var reportedByUsername sql.NullString
786
2x
        var contentJSON string
787
2x
        var totalResponses int
788
2x

789
2x
        err = rows.Scan(
790
2x
            &question.ID,
791
2x
            &question.Type,
792
2x
            &question.Language,
793
2x
            &question.Level,
794
2x
            &question.DifficultyScore,
795
2x
            &contentJSON,
796
2x
            &question.CorrectAnswer,
797
2x
            &question.Explanation,
798
2x
            &question.CreatedAt,
799
2x
            &question.Status,
800
2x
            &reportedByUsername,
801
2x
            &totalResponses,
802
2x
        )
803
2x
        if err != nil {
804
            return nil, err
805
        }
806

807
2x
        if err := question.UnmarshalContentFromJSON(contentJSON); err != nil {
808
            return nil, err
809
        }
810

811
2x
        username := ""
812
2x
        if reportedByUsername.Valid {
813
2x
            username = reportedByUsername.String
814
2x
        }
815

816
2x
        reportedQuestion := &ReportedQuestionWithUser{
817
2x
            Question:           &question,
818
2x
            ReportedByUsername: username,
819
2x
            TotalResponses:     totalResponses,
820
2x
        }
821
2x

822
2x
        questions = append(questions, reportedQuestion)
823
    }
824

825
1x
    return questions, nil
826
}
827

828
// MarkQuestionAsFixed marks a reported question as fixed and puts it back in rotation
829
1x
func (s *QuestionService) MarkQuestionAsFixed(ctx context.Context, questionID int) (err error) {
830
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "mark_question_as_fixed", observability.AttributeQuestionID(questionID))
831
1x
    defer func() {
832
1x
        if err != nil {
833
            span.RecordError(err, trace.WithStackTrace(true))
834
            span.SetStatus(codes.Error, err.Error())
835
        }
836
1x
        span.End()
837
    }()
838

839
1x
    query := `UPDATE questions SET status = $1 WHERE id = $2`
840
1x
    var result sql.Result
841
1x
    result, err = s.db.ExecContext(ctx, query, models.QuestionStatusActive, questionID)
842
1x
    if err != nil {
843
        return contextutils.WrapError(err, "failed to mark question as fixed")
844
    }
845

846
    // Check if the question was actually updated
847
1x
    rowsAffected, err := result.RowsAffected()
848
1x
    if err != nil {
849
        return contextutils.WrapError(err, "failed to get rows affected")
850
    }
851

852
1x
    if rowsAffected == 0 {
853
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with ID %d not found", questionID)
854
    }
855

856
1x
    return nil
857
}
858

859
// UpdateQuestion updates a question's content, correct answer, and explanation
860
1x
func (s *QuestionService) UpdateQuestion(ctx context.Context, questionID int, content map[string]interface{}, correctAnswerIndex int, explanation string) (err error) {
861
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "update_question", observability.AttributeQuestionID(questionID))
862
1x
    defer func() {
863
1x
        if err != nil {
864
            span.RecordError(err, trace.WithStackTrace(true))
865
            span.SetStatus(codes.Error, err.Error())
866
        }
867
1x
        span.End()
868
    }()
869
1x
    var contentJSON []byte
870
1x
    // Marshal provided content map via a temporary Question instance to reuse method
871
1x
    tempQ := &models.Question{Content: content}
872
1x
    contentJSONStr, err := tempQ.MarshalContentToJSON()
873
1x
    if err != nil {
874
        return contextutils.WrapError(err, "failed to marshal content JSON")
875
    }
876
1x
    contentJSON = []byte(contentJSONStr)
877
1x
    if err != nil {
878
        return contextutils.WrapError(err, "failed to marshal content JSON")
879
    }
880

881
1x
    query := `UPDATE questions SET content = $1, correct_answer = $2, explanation = $3 WHERE id = $4`
882
1x
    var result sql.Result
883
1x
    result, err = s.db.ExecContext(ctx, query, string(contentJSON), correctAnswerIndex, explanation, questionID)
884
1x
    if err != nil {
885
        return contextutils.WrapError(err, "failed to update question")
886
    }
887

888
    // Check if the question was actually updated
889
1x
    rowsAffected, err := result.RowsAffected()
890
1x
    if err != nil {
891
        return contextutils.WrapError(err, "failed to get rows affected")
892
    }
893

894
1x
    if rowsAffected == 0 {
895
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with ID %d not found", questionID)
896
    }
897

898
1x
    return nil
899
}
900

901
// DeleteQuestion permanently deletes a question from the database
902
1x
func (s *QuestionService) DeleteQuestion(ctx context.Context, questionID int) (err error) {
903
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "delete_question", observability.AttributeQuestionID(questionID))
904
1x
    defer func() {
905
1x
        if err != nil {
906
            span.RecordError(err, trace.WithStackTrace(true))
907
            span.SetStatus(codes.Error, err.Error())
908
        }
909
1x
        span.End()
910
    }()
911
    // First, delete associated user responses
912
1x
    deleteResponsesQuery := `DELETE FROM user_responses WHERE question_id = $1`
913
1x
    _, err = s.db.ExecContext(ctx, deleteResponsesQuery, questionID)
914
1x
    if err != nil {
915
        return contextutils.WrapError(err, "failed to delete associated user responses")
916
    }
917

918
    // Then delete the question itself
919
1x
    deleteQuestionQuery := `DELETE FROM questions WHERE id = $1`
920
1x
    var result sql.Result
921
1x
    result, err = s.db.ExecContext(ctx, deleteQuestionQuery, questionID)
922
1x
    if err != nil {
923
        return contextutils.WrapError(err, "failed to delete question")
924
    }
925

926
    // Check if the question was actually deleted
927
1x
    rowsAffected, err := result.RowsAffected()
928
1x
    if err != nil {
929
        return contextutils.WrapError(err, "failed to get rows affected")
930
    }
931

932
1x
    if rowsAffected == 0 {
933
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with ID %d not found", questionID)
934
    }
935

936
1x
    return nil
937
}
938

939
// ReportQuestion marks a question as reported/problematic by a specific user
940
11x
func (s *QuestionService) ReportQuestion(ctx context.Context, questionID, userID int, reportReason string) (err error) {
941
11x
    ctx, span := observability.TraceQuestionFunction(ctx, "report_question", observability.AttributeQuestionID(questionID), observability.AttributeUserID(userID))
942
11x
    defer func() {
943
11x
        if err != nil {
944
            span.RecordError(err, trace.WithStackTrace(true))
945
            span.SetStatus(codes.Error, err.Error())
946
        }
947
11x
        span.End()
948
    }()
949

950
    // Start a transaction
951
11x
    tx, err := s.db.BeginTx(ctx, nil)
952
11x
    if err != nil {
953
        return contextutils.WrapError(err, "failed to begin transaction")
954
    }
955
11x
    defer func() {
956
11x
        if err != nil {
957
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
958
                s.logger.Warn(ctx, "Failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
959
            }
960
        }
961
    }()
962

963
    // Check if question exists first
964
11x
    var questionExists bool
965
11x
    err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM questions WHERE id = $1)`, questionID).Scan(&questionExists)
966
11x
    if err != nil {
967
        return contextutils.WrapError(err, "failed to check if question exists")
968
    }
969
11x
    if !questionExists {
970
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with id %d not found", questionID)
971
    }
972

973
    // Update question status to reported
974
11x
    updateQuery := `UPDATE questions SET status = $1 WHERE id = $2`
975
11x
    var result sql.Result
976
11x
    result, err = tx.ExecContext(ctx, updateQuery, models.QuestionStatusReported, questionID)
977
11x
    if err != nil {
978
        return contextutils.WrapError(err, "failed to update question status")
979
    }
980

981
    // Check if the question was actually updated
982
11x
    rowsAffected, err := result.RowsAffected()
983
11x
    if err != nil {
984
        return contextutils.WrapError(err, "failed to get rows affected")
985
    }
986

987
11x
    if rowsAffected == 0 {
988
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "question with ID %d not found", questionID)
989
    }
990

991
    // Use provided report reason or default message
992
11x
    reason := reportReason
993
11x
    if reason == "" {
994
4x
        reason = "Question reported by user"
995
4x
    }
996

997
    // Create or update a report record: if the same user reports the same question again,
998
    // update the report_reason to the new value instead of doing nothing. Also update created_at
999
    // so admin views show the time of the latest report by that user.
1000
11x
    reportQuery := `INSERT INTO question_reports (question_id, reported_by_user_id, report_reason) VALUES ($1, $2, $3) ON CONFLICT (question_id, reported_by_user_id) DO UPDATE SET report_reason = EXCLUDED.report_reason, created_at = now()`
1001
11x
    _, err = tx.ExecContext(ctx, reportQuery, questionID, userID, reason)
1002
11x
    if err != nil {
1003
        return contextutils.WrapError(err, "failed to create question report")
1004
    }
1005

1006
    // Commit the transaction
1007
11x
    err = tx.Commit()
1008
11x
    if err != nil {
1009
        return contextutils.WrapError(err, "failed to commit transaction")
1010
    }
1011

1012
11x
    return nil
1013
}
1014

1015
// GetNextQuestion gets the next question for a user based on usage count and availability
1016
206x
func (s *QuestionService) GetNextQuestion(ctx context.Context, userID int, language, level string, qType models.QuestionType) (result0 *QuestionWithStats, err error) {
1017
206x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_next_question", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
1018
206x
    defer func() {
1019
206x
        if err != nil {
1020
            span.RecordError(err, trace.WithStackTrace(true))
1021
            span.SetStatus(codes.Error, err.Error())
1022
        }
1023
206x
        span.End()
1024
    }()
1025
    // Use priority-based selection with stats included
1026
206x
    return s.getNextQuestionWithPriority(ctx, userID, language, level, qType)
1027
}
1028

1029
// getNextQuestionWithPriority implements priority-based question selection with stats
1030
206x
func (s *QuestionService) getNextQuestionWithPriority(ctx context.Context, userID int, language, level string, qType models.QuestionType) (result0 *QuestionWithStats, err error) {
1031
206x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_next_question_with_priority", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
1032
206x
    defer func() {
1033
206x
        if err != nil {
1034
            span.RecordError(err, trace.WithStackTrace(true))
1035
            span.SetStatus(codes.Error, err.Error())
1036
        }
1037
206x
        span.End()
1038
    }()
1039
    // Get user preferences
1040
206x
    var prefs *models.UserLearningPreferences
1041
206x
    prefs, err = s.learningService.GetUserLearningPreferences(ctx, userID)
1042
206x
    if err != nil {
1043
        s.logger.Warn(ctx, "Failed to get user preferences", map[string]interface{}{"user_id": userID, "error": err.Error()})
1044
        // Fall back to default preferences
1045
        prefs = s.learningService.GetDefaultLearningPreferences()
1046
    }
1047

1048
    // Get available questions with priority scores and stats
1049
206x
    var questions []*QuestionWithStats
1050
206x
    questions, err = s.getAvailableQuestionsWithPriority(ctx, userID, language, level, qType, prefs)
1051
206x
    if err != nil {
1052
        return nil, contextutils.WrapError(err, "failed to get available questions")
1053
    }
1054

1055
206x
    if len(questions) == 0 {
1056
3x
        // Fallback: try to get a random global question and assign it to the user
1057
3x
        globalQ, err := s.GetRandomGlobalQuestionForUser(ctx, userID, language, level, qType)
1058
3x
        if err != nil {
1059
            return nil, contextutils.WrapError(err, "no personalized questions, and failed to get global fallback question")
1060
        }
1061
3x
        if globalQ != nil {
1062
2x
            return globalQ, nil
1063
2x
        }
1064
1x
        return nil, nil // No questions available at all
1065
    }
1066

1067
    // Apply FreshQuestionRatio logic (NEW)
1068
203x
    selectedQuestion, err := s.selectQuestionWithFreshnessRatio(questions, prefs.FreshQuestionRatio)
1069
203x
    if err != nil {
1070
        return nil, contextutils.WrapError(err, "failed to select question with freshness ratio")
1071
    }
1072

1073
    // Return the selected question with stats (already included)
1074
203x
    return selectedQuestion, nil
1075
}
1076

1077
// GetAdaptiveQuestionsForDaily selects multiple adaptive questions for daily assignments
1078
51x
func (s *QuestionService) GetAdaptiveQuestionsForDaily(ctx context.Context, userID int, language, level string, limit int) (result0 []*QuestionWithStats, err error) {
1079
51x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_adaptive_questions_for_daily")
1080
51x
    defer func() {
1081
51x
        if err != nil {
1082
            span.RecordError(err, trace.WithStackTrace(true))
1083
            span.SetStatus(codes.Error, err.Error())
1084
        }
1085
51x
        span.End()
1086
    }()
1087

1088
    // Get user learning preferences
1089
51x
    prefs, err := s.learningService.GetUserLearningPreferences(ctx, userID)
1090
51x
    if err != nil {
1091
        s.logger.Warn(ctx, "Failed to get user learning preferences, using defaults", map[string]interface{}{
1092
            "user_id": userID, "error": err.Error(),
1093
        })
1094
        prefs = &models.UserLearningPreferences{
1095
            FreshQuestionRatio: 0.7,
1096
        }
1097
    }
1098

1099
51x
    var selectedQuestions []*QuestionWithStats
1100
51x
    selectedQuestionIDs := make(map[int]bool) // Track selected question IDs to prevent duplicates
1101
51x

1102
51x
    // Select questions across different types to provide variety
1103
51x
    questionTypes := []models.QuestionType{models.Vocabulary, models.FillInBlank, models.QuestionAnswer, models.ReadingComprehension}
1104
51x

1105
51x
    // Calculate how many questions to select from each type
1106
51x
    questionsPerType := limit / len(questionTypes)
1107
51x
    remainingQuestions := limit % len(questionTypes)
1108
51x

1109
51x
    for i, qType := range questionTypes {
1110
204x
        // Calculate how many questions to get for this type
1111
204x
        currentLimit := questionsPerType
1112
204x
        if i < remainingQuestions {
1113
42x
            currentLimit++ // Distribute remaining questions evenly
1114
42x
        }
1115

1116
204x
        if currentLimit == 0 {
1117
8x
            continue
1118
        }
1119

1120
        // Get available questions for DAILY with 2-day recent-correct exclusion
1121
196x
        questions, err := s.getAvailableQuestionsForDailyWithPriority(ctx, userID, language, level, qType, prefs)
1122
196x
        if err != nil {
1123
            s.logger.Warn(ctx, "Failed to get questions for type", map[string]interface{}{
1124
                "user_id": userID, "type": qType, "error": err.Error(),
1125
            })
1126
            continue
1127
        }
1128

1129
        // Filter out questions that have already been selected
1130
196x
        var availableQuestions []*QuestionWithStats
1131
196x
        for _, q := range questions {
1132
942x
            if !selectedQuestionIDs[q.ID] {
1133
942x
                availableQuestions = append(availableQuestions, q)
1134
942x
            }
1135
        }
1136

1137
196x
        if len(availableQuestions) == 0 {
1138
25x
            // Try to get a global fallback question for this type
1139
25x
            globalQ, err := s.GetRandomGlobalQuestionForUser(ctx, userID, language, level, qType)
1140
25x
            if err != nil {
1141
                s.logger.Warn(ctx, "Failed to get global fallback question", map[string]interface{}{
1142
                    "user_id": userID, "type": qType, "error": err.Error(),
1143
                })
1144
                continue
1145
            }
1146
25x
            if globalQ != nil && !selectedQuestionIDs[globalQ.ID] {
1147
                selectedQuestions = append(selectedQuestions, globalQ)
1148
                selectedQuestionIDs[globalQ.ID] = true
1149
                s.logger.Info(ctx, "Added global fallback question", map[string]interface{}{
1150
                    "user_id": userID, "type": qType, "question_id": globalQ.ID,
1151
                })
1152
            }
1153
25x
            continue
1154
        }
1155

1156
        // Select questions for this type using adaptive selection
1157
171x
        s.logger.Info(ctx, "Starting selection for question type", map[string]interface{}{
1158
171x
            "user_id": userID, "type": qType, "current_limit": currentLimit, "available_questions": len(availableQuestions),
1159
171x
        })
1160
171x

1161
171x
        questionsSelected := 0
1162
171x
        remainingQuestionsForType := availableQuestions
1163
171x

1164
171x
        for j := 0; j < currentLimit && len(remainingQuestionsForType) > 0; j++ {
1165
696x
            // Apply freshness ratio logic for each selection
1166
696x
            selectedQuestion, err := s.selectQuestionWithFreshnessRatio(remainingQuestionsForType, prefs.FreshQuestionRatio)
1167
696x
            if err != nil {
1168
                s.logger.Warn(ctx, "Failed to select question with freshness ratio", map[string]interface{}{
1169
                    "user_id": userID, "type": qType, "error": err.Error(),
1170
                })
1171
                // Fallback to simple random selection
1172
                if len(remainingQuestionsForType) > 0 {
1173
                    selectedQuestion = remainingQuestionsForType[rand.Intn(len(remainingQuestionsForType))]
1174
                } else {
1175
                    break
1176
                }
1177
            }
1178

1179
696x
            if selectedQuestion != nil && !selectedQuestionIDs[selectedQuestion.ID] {
1180
696x
                selectedQuestions = append(selectedQuestions, selectedQuestion)
1181
696x
                selectedQuestionIDs[selectedQuestion.ID] = true
1182
696x
                questionsSelected++
1183
696x

1184
696x
                // Remove the selected question from the remaining pool
1185
696x
                var newRemainingQuestions []*QuestionWithStats
1186
696x
                for _, q := range remainingQuestionsForType {
1187
4247x
                    if q.ID != selectedQuestion.ID {
1188
3551x
                        newRemainingQuestions = append(newRemainingQuestions, q)
1189
3551x
                    }
1190
                }
1191
696x
                remainingQuestionsForType = newRemainingQuestions
1192
696x

1193
696x
                s.logger.Info(ctx, "Successfully selected question", map[string]interface{}{
1194
696x
                    "user_id": userID, "type": qType, "iteration": j, "question_id": selectedQuestion.ID,
1195
696x
                    "total_selected": len(selectedQuestions),
1196
696x
                })
1197
            } else {
1198
                s.logger.Warn(ctx, "Failed to select question for type", map[string]interface{}{
1199
                    "user_id": userID, "type": qType, "iteration": j, "current_limit": currentLimit,
1200
                    "selected_question_nil": selectedQuestion == nil,
1201
                    "already_selected":      selectedQuestion != nil && selectedQuestionIDs[selectedQuestion.ID],
1202
                })
1203
                // Remove the question from the pool even if it was already selected
1204
                if selectedQuestion != nil {
1205
                    var newRemainingQuestions []*QuestionWithStats
1206
                    for _, q := range remainingQuestionsForType {
1207
                        if q.ID != selectedQuestion.ID {
1208
                            newRemainingQuestions = append(newRemainingQuestions, q)
1209
                        }
1210
                    }
1211
                    remainingQuestionsForType = newRemainingQuestions
1212
                }
1213
            }
1214
        }
1215

1216
        // If we didn't select enough questions for this type, try simple selection from all available questions
1217
171x
        if questionsSelected < currentLimit {
1218
60x
            s.logger.Info(ctx, "Using simple selection to fill remaining slots", map[string]interface{}{
1219
60x
                "user_id": userID, "type": qType, "questions_selected": questionsSelected, "current_limit": currentLimit,
1220
60x
            })
1221
60x

1222
60x
            // Get all questions for this type again and filter out already selected ones
1223
60x
            allQuestionsForType, err := s.getAvailableQuestionsForDailyWithPriority(ctx, userID, language, level, qType, prefs)
1224
60x
            if err == nil {
1225
60x
                for _, q := range allQuestionsForType {
1226
179x
                    if !selectedQuestionIDs[q.ID] && questionsSelected < currentLimit {
1227
                        selectedQuestions = append(selectedQuestions, q)
1228
                        selectedQuestionIDs[q.ID] = true
1229
                        questionsSelected++
1230
                    }
1231
                }
1232
            }
1233
        }
1234

1235
171x
        s.logger.Info(ctx, "Completed selection for question type", map[string]interface{}{
1236
171x
            "user_id": userID, "type": qType, "questions_selected": questionsSelected, "target": currentLimit,
1237
171x
        })
1238
    }
1239

1240
    // If we don't have enough questions, fill with random questions from any type
1241
51x
    if len(selectedQuestions) < limit {
1242
26x
        remainingNeeded := limit - len(selectedQuestions)
1243
26x
        s.logger.Info(ctx, "Not enough questions from type-based selection, using fallback", map[string]interface{}{
1244
26x
            "user_id": userID, "selected_count": len(selectedQuestions), "limit": limit, "remaining_needed": remainingNeeded,
1245
26x
        })
1246
26x

1247
26x
        // Get all available questions by trying each question type
1248
26x
        var allQuestions []*QuestionWithStats
1249
26x
        questionIDMap := make(map[int]bool) // Track seen question IDs to avoid duplicates
1250
26x

1251
26x
        for _, qType := range questionTypes {
1252
104x
            questions, err := s.getAvailableQuestionsForDailyWithPriority(ctx, userID, language, level, qType, prefs)
1253
104x
            if err == nil {
1254
104x
                for _, q := range questions {
1255
299x
                    if !questionIDMap[q.ID] && !selectedQuestionIDs[q.ID] {
1256
69x
                        allQuestions = append(allQuestions, q)
1257
69x
                        questionIDMap[q.ID] = true
1258
69x
                    }
1259
                }
1260
            }
1261
        }
1262

1263
26x
        s.logger.Info(ctx, "Fallback questions available", map[string]interface{}{
1264
26x
            "user_id": userID, "all_questions_count": len(allQuestions),
1265
26x
        })
1266
26x

1267
26x
        if len(allQuestions) > 0 {
1268
9x
            // Select random questions to fill the remaining slots
1269
9x
            for i := 0; i < remainingNeeded && i < len(allQuestions); i++ {
1270
22x
                selectedQuestion, err := s.selectQuestionWithFreshnessRatio(allQuestions, prefs.FreshQuestionRatio)
1271
22x
                if err != nil {
1272
                    s.logger.Warn(ctx, "Failed to select question with freshness ratio in fallback", map[string]interface{}{
1273
                        "user_id": userID, "error": err.Error(),
1274
                    })
1275
                    // Fallback to simple random selection
1276
                    if len(allQuestions) > 0 {
1277
                        selectedQuestion = allQuestions[rand.Intn(len(allQuestions))]
1278
                    } else {
1279
                        break
1280
                    }
1281
                }
1282

1283
22x
                if selectedQuestion != nil && !selectedQuestionIDs[selectedQuestion.ID] {
1284
22x
                    selectedQuestions = append(selectedQuestions, selectedQuestion)
1285
22x
                    selectedQuestionIDs[selectedQuestion.ID] = true
1286
22x

1287
22x
                    // Remove the selected question from the pool
1288
22x
                    var newAllQuestions []*QuestionWithStats
1289
22x
                    for _, q := range allQuestions {
1290
178x
                        if q.ID != selectedQuestion.ID {
1291
156x
                            newAllQuestions = append(newAllQuestions, q)
1292
156x
                        }
1293
                    }
1294
22x
                    allQuestions = newAllQuestions
1295
                } else if selectedQuestion != nil {
1296
                    // Remove the question from the pool even if it was already selected
1297
                    var newAllQuestions []*QuestionWithStats
1298
                    for _, q := range allQuestions {
1299
                        if q.ID != selectedQuestion.ID {
1300
                            newAllQuestions = append(newAllQuestions, q)
1301
                        }
1302
                    }
1303
                    allQuestions = newAllQuestions
1304
                }
1305
            }
1306
        }
1307
    }
1308

1309
    // Ensure we don't exceed the limit
1310
51x
    if len(selectedQuestions) > limit {
1311
        selectedQuestions = selectedQuestions[:limit]
1312
    }
1313

1314
    // Final duplicate check - this should never happen but provides extra safety
1315
51x
    finalSelectedQuestions := make([]*QuestionWithStats, 0, len(selectedQuestions))
1316
51x
    finalSelectedIDs := make(map[int]bool)
1317
51x

1318
51x
    for _, q := range selectedQuestions {
1319
718x
        if !finalSelectedIDs[q.ID] {
1320
718x
            finalSelectedQuestions = append(finalSelectedQuestions, q)
1321
718x
            finalSelectedIDs[q.ID] = true
1322
718x
        } else {
1323
            s.logger.Warn(ctx, "Duplicate question detected in final selection", map[string]interface{}{
1324
                "user_id": userID, "question_id": q.ID,
1325
            })
1326
        }
1327
    }
1328

1329
51x
    s.logger.Info(ctx, "Selected adaptive questions for daily assignment", map[string]interface{}{
1330
51x
        "user_id":            userID,
1331
51x
        "language":           language,
1332
51x
        "level":              level,
1333
51x
        "requested_limit":    limit,
1334
51x
        "selected_count":     len(finalSelectedQuestions),
1335
51x
        "duplicates_removed": len(selectedQuestions) - len(finalSelectedQuestions),
1336
51x
    })
1337
51x

1338
51x
    return finalSelectedQuestions, nil
1339
}
1340

1341
// GetQuestionStats returns basic statistics about questions in the system
1342
1x
func (s *QuestionService) GetQuestionStats(ctx context.Context) (result0 map[string]interface{}, err error) {
1343
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_question_stats")
1344
1x
    defer func() {
1345
1x
        if err != nil {
1346
            span.RecordError(err, trace.WithStackTrace(true))
1347
            span.SetStatus(codes.Error, err.Error())
1348
        }
1349
1x
        span.End()
1350
    }()
1351
1x
    stats := make(map[string]interface{})
1352
1x

1353
1x
    // Total questions
1354
1x
    var totalQuestions int
1355
1x
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM questions").Scan(&totalQuestions)
1356
1x
    if err != nil {
1357
        return nil, contextutils.WrapError(err, "failed to get total questions count")
1358
    }
1359
1x
    stats["total_questions"] = totalQuestions
1360
1x

1361
1x
    // Questions by type
1362
1x
    typeQuery := `
1363
1x
        SELECT type, COUNT(*) as count
1364
1x
        FROM questions
1365
1x
        GROUP BY type
1366
1x
    `
1367
1x
    rows, err := s.db.QueryContext(ctx, typeQuery)
1368
1x
    if err != nil {
1369
        return nil, contextutils.WrapError(err, "failed to query questions by type")
1370
    }
1371
1x
    defer func() {
1372
1x
        if err := rows.Close(); err != nil {
1373
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1374
        }
1375
    }()
1376

1377
1x
    questionsByType := make(map[string]int)
1378
1x
    for rows.Next() {
1379
2x
        var qType string
1380
2x
        var count int
1381
2x
        if err := rows.Scan(&qType, &count); err != nil {
1382
            return nil, contextutils.WrapError(err, "failed to scan question type count")
1383
        }
1384
2x
        questionsByType[qType] = count
1385
    }
1386
1x
    stats["questions_by_type"] = questionsByType
1387
1x

1388
1x
    // Questions by level
1389
1x
    levelQuery := `
1390
1x
        SELECT level, COUNT(*) as count
1391
1x
        FROM questions
1392
1x
        GROUP BY level
1393
1x
    `
1394
1x
    rows, err = s.db.QueryContext(ctx, levelQuery)
1395
1x
    if err != nil {
1396
        return nil, contextutils.WrapError(err, "failed to query questions by level")
1397
    }
1398
1x
    defer func() {
1399
1x
        if err := rows.Close(); err != nil {
1400
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1401
        }
1402
    }()
1403

1404
1x
    questionsByLevel := make(map[string]int)
1405
1x
    for rows.Next() {
1406
1x
        var level string
1407
1x
        var count int
1408
1x
        if err := rows.Scan(&level, &count); err != nil {
1409
            return nil, err
1410
        }
1411
1x
        questionsByLevel[level] = count
1412
    }
1413
1x
    stats["questions_by_level"] = questionsByLevel
1414
1x

1415
1x
    return stats, nil
1416
}
1417

1418
// GetDetailedQuestionStats returns detailed statistics about questions
1419
1x
func (s *QuestionService) GetDetailedQuestionStats(ctx context.Context) (result0 map[string]interface{}, err error) {
1420
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_detailed_question_stats")
1421
1x
    defer func() {
1422
1x
        if err != nil {
1423
            span.RecordError(err, trace.WithStackTrace(true))
1424
            span.SetStatus(codes.Error, err.Error())
1425
        }
1426
1x
        span.End()
1427
    }()
1428
1x
    stats := make(map[string]interface{})
1429
1x

1430
1x
    // Total questions
1431
1x
    var totalQuestions int
1432
1x
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM questions").Scan(&totalQuestions)
1433
1x
    if err != nil {
1434
        return nil, err
1435
    }
1436
1x
    stats["total_questions"] = totalQuestions
1437
1x

1438
1x
    // Questions by language, level, and type combination
1439
1x
    detailQuery := `
1440
1x
        SELECT language, level, type, COUNT(*) as count
1441
1x
        FROM questions
1442
1x
        GROUP BY language, level, type
1443
1x
        ORDER BY language, level, type
1444
1x
    `
1445
1x
    rows, err := s.db.QueryContext(ctx, detailQuery)
1446
1x
    if err != nil {
1447
        return nil, err
1448
    }
1449
1x
    defer func() {
1450
1x
        if err := rows.Close(); err != nil {
1451
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1452
        }
1453
    }()
1454

1455
    // Create nested structure: language -> level -> type -> count
1456
1x
    questionsByDetail := make(map[string]map[string]map[string]int)
1457
1x
    for rows.Next() {
1458
1x
        var language, level, qType string
1459
1x
        var count int
1460
1x
        if err := rows.Scan(&language, &level, &qType, &count); err != nil {
1461
            return nil, err
1462
        }
1463

1464
1x
        if questionsByDetail[language] == nil {
1465
1x
            questionsByDetail[language] = make(map[string]map[string]int)
1466
1x
        }
1467
1x
        if questionsByDetail[language][level] == nil {
1468
1x
            questionsByDetail[language][level] = make(map[string]int)
1469
1x
        }
1470
1x
        questionsByDetail[language][level][qType] = count
1471
    }
1472
1x
    stats["questions_by_detail"] = questionsByDetail
1473
1x

1474
1x
    // Questions by language
1475
1x
    languageQuery := `
1476
1x
        SELECT language, COUNT(*) as count
1477
1x
        FROM questions
1478
1x
        GROUP BY language
1479
1x
    `
1480
1x
    rows, err = s.db.QueryContext(ctx, languageQuery)
1481
1x
    if err != nil {
1482
        return nil, err
1483
    }
1484
1x
    defer func() {
1485
1x
        if err := rows.Close(); err != nil {
1486
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1487
        }
1488
    }()
1489

1490
1x
    questionsByLanguage := make(map[string]int)
1491
1x
    for rows.Next() {
1492
1x
        var language string
1493
1x
        var count int
1494
1x
        if err := rows.Scan(&language, &count); err != nil {
1495
            return nil, err
1496
        }
1497
1x
        questionsByLanguage[language] = count
1498
    }
1499
1x
    stats["questions_by_language"] = questionsByLanguage
1500
1x

1501
1x
    // Questions by type
1502
1x
    typeQuery := `
1503
1x
        SELECT type, COUNT(*) as count
1504
1x
        FROM questions
1505
1x
        GROUP BY type
1506
1x
    `
1507
1x
    rows, err = s.db.QueryContext(ctx, typeQuery)
1508
1x
    if err != nil {
1509
        return nil, err
1510
    }
1511
1x
    defer func() {
1512
1x
        if err := rows.Close(); err != nil {
1513
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1514
        }
1515
    }()
1516

1517
1x
    questionsByType := make(map[string]int)
1518
1x
    for rows.Next() {
1519
1x
        var qType string
1520
1x
        var count int
1521
1x
        if err := rows.Scan(&qType, &count); err != nil {
1522
            return nil, err
1523
        }
1524
1x
        questionsByType[qType] = count
1525
    }
1526
1x
    stats["questions_by_type"] = questionsByType
1527
1x

1528
1x
    // Questions by level
1529
1x
    levelQuery := `
1530
1x
        SELECT level, COUNT(*) as count
1531
1x
        FROM questions
1532
1x
        GROUP BY level
1533
1x
    `
1534
1x
    rows, err = s.db.QueryContext(ctx, levelQuery)
1535
1x
    if err != nil {
1536
        return nil, err
1537
    }
1538
1x
    defer func() {
1539
1x
        if err := rows.Close(); err != nil {
1540
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1541
        }
1542
    }()
1543

1544
1x
    questionsByLevel := make(map[string]int)
1545
1x
    for rows.Next() {
1546
1x
        var level string
1547
1x
        var count int
1548
1x
        if err := rows.Scan(&level, &count); err != nil {
1549
            return nil, err
1550
        }
1551
1x
        questionsByLevel[level] = count
1552
    }
1553
1x
    stats["questions_by_level"] = questionsByLevel
1554
1x

1555
1x
    return stats, nil
1556
}
1557

1558
// GetRecentQuestionContentsForUser retrieves recent question contents for a user
1559
1x
func (s *QuestionService) GetRecentQuestionContentsForUser(ctx context.Context, userID, limit int) (result0 []string, err error) {
1560
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_recent_question_contents_for_user", observability.AttributeUserID(userID), observability.AttributeLimit(limit))
1561
1x
    defer func() {
1562
1x
        if err != nil {
1563
            span.RecordError(err, trace.WithStackTrace(true))
1564
            span.SetStatus(codes.Error, err.Error())
1565
        }
1566
1x
        span.End()
1567
    }()
1568
1x
    query := `
1569
1x
        SELECT DISTINCT q.content
1570
1x
        FROM user_responses ur
1571
1x
        JOIN questions q ON ur.question_id = q.id
1572
1x
        JOIN user_questions uq ON q.id = uq.question_id
1573
1x
        WHERE ur.user_id = $1 AND uq.user_id = $2
1574
1x
        ORDER BY q.content DESC
1575
1x
        LIMIT $3
1576
1x
    `
1577
1x

1578
1x
    var rows *sql.Rows
1579
1x
    rows, err = s.db.QueryContext(ctx, query, userID, userID, limit)
1580
1x
    if err != nil {
1581
        return []string{}, err
1582
    }
1583
1x
    defer func() {
1584
1x
        if err := rows.Close(); err != nil {
1585
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1586
        }
1587
    }()
1588

1589
1x
    var contents []string
1590
1x
    for rows.Next() {
1591
1x
        var content string
1592
1x
        if err := rows.Scan(&content); err != nil {
1593
            return []string{}, err
1594
        }
1595
1x
        contents = append(contents, content)
1596
    }
1597

1598
    // Ensure we always return an empty slice instead of nil
1599
1x
    if contents == nil {
1600
        contents = []string{}
1601
    }
1602

1603
1x
    return contents, nil
1604
}
1605

1606
// GetUserQuestions retrieves actual questions for a user (not just content)
1607
6x
func (s *QuestionService) GetUserQuestions(ctx context.Context, userID, limit int) (result0 []*models.Question, err error) {
1608
6x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_user_questions", observability.AttributeUserID(userID), observability.AttributeLimit(limit))
1609
6x
    defer func() {
1610
6x
        if err != nil {
1611
            span.RecordError(err, trace.WithStackTrace(true))
1612
            span.SetStatus(codes.Error, err.Error())
1613
        }
1614
6x
        span.End()
1615
    }()
1616
6x
    query := `
1617
6x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status, q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context
1618
6x
        FROM questions q
1619
6x
        JOIN user_questions uq ON q.id = uq.question_id
1620
6x
        WHERE uq.user_id = $1
1621
6x
        ORDER BY q.created_at DESC
1622
6x
        LIMIT $2
1623
6x
    `
1624
6x

1625
6x
    var rows *sql.Rows
1626
6x
    rows, err = s.db.QueryContext(ctx, query, userID, limit)
1627
6x
    if err != nil {
1628
        return nil, err
1629
    }
1630
6x
    defer func() {
1631
6x
        if err := rows.Close(); err != nil {
1632
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1633
        }
1634
    }()
1635

1636
6x
    var questions []*models.Question
1637
6x
    for rows.Next() {
1638
13x
        question, err := s.scanQuestionFromRows(rows)
1639
13x
        if err != nil {
1640
            return nil, err
1641
        }
1642
13x
        questions = append(questions, question)
1643
    }
1644

1645
6x
    return questions, nil
1646
}
1647

1648
// GetUserQuestionsWithStats retrieves questions for a user with response statistics
1649
4x
func (s *QuestionService) GetUserQuestionsWithStats(ctx context.Context, userID, limit int) (result0 []*QuestionWithStats, err error) {
1650
4x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_user_questions_with_stats", observability.AttributeUserID(userID), observability.AttributeLimit(limit))
1651
4x
    defer func() {
1652
4x
        if err != nil {
1653
            span.RecordError(err, trace.WithStackTrace(true))
1654
            span.SetStatus(codes.Error, err.Error())
1655
        }
1656
4x
        span.End()
1657
    }()
1658
4x
    query := `
1659
4x
        SELECT
1660
4x
            q.id, q.type, q.language, q.level, q.difficulty_score,
1661
4x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1662
4x
            COALESCE(SUM(CASE WHEN ur.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
1663
4x
            COALESCE(SUM(CASE WHEN ur.is_correct = false THEN 1 ELSE 0 END), 0) as incorrect_count,
1664
4x
            COALESCE(COUNT(ur.id), 0) as total_responses,
1665
4x
            COALESCE(uq_stats.user_count, 0) as user_count
1666
4x
        FROM questions q
1667
4x
        JOIN user_questions uq ON q.id = uq.question_id
1668
4x
        LEFT JOIN user_responses ur ON q.id = ur.question_id
1669
4x
        LEFT JOIN (
1670
4x
            SELECT
1671
4x
                question_id,
1672
4x
                COUNT(*) as user_count
1673
4x
            FROM user_questions
1674
4x
            GROUP BY question_id
1675
4x
        ) uq_stats ON q.id = uq_stats.question_id
1676
4x
        WHERE uq.user_id = $1
1677
4x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score,
1678
4x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1679
4x
            uq_stats.user_count
1680
4x
        ORDER BY q.created_at DESC
1681
4x
        LIMIT $2
1682
4x
    `
1683
4x

1684
4x
    rows, err := s.db.QueryContext(ctx, query, userID, limit)
1685
4x
    if err != nil {
1686
        return nil, err
1687
    }
1688
4x
    defer func() {
1689
4x
        if err := rows.Close(); err != nil {
1690
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1691
        }
1692
    }()
1693

1694
4x
    var questions []*QuestionWithStats
1695
4x
    for rows.Next() {
1696
66x
        questionWithStats, err := s.scanQuestionWithStatsFromRows(rows)
1697
66x
        if err != nil {
1698
            return nil, err
1699
        }
1700
66x
        questions = append(questions, questionWithStats)
1701
    }
1702

1703
4x
    if err = rows.Err(); err != nil {
1704
        return nil, err
1705
    }
1706

1707
4x
    return questions, nil
1708
}
1709

1710
// QuestionWithStats represents a question with response statistics
1711
type QuestionWithStats struct {
1712
    *models.Question
1713
    CorrectCount   int `json:"correct_count"`
1714
    IncorrectCount int `json:"incorrect_count"`
1715
    TotalResponses int `json:"total_responses"`
1716
    // TimesAnswered tracks how many times THIS user answered the question (per-user)
1717
    TimesAnswered   int    `json:"times_answered"`
1718
    UserCount       int    `json:"user_count"`
1719
    Reporters       string `json:"reporters,omitempty"`
1720
    ReportReasons   string `json:"report_reasons,omitempty"`
1721
    ConfidenceLevel *int   `json:"confidence_level,omitempty"`
1722
}
1723

1724
// GetQuestionsPaginated retrieves questions with pagination and response statistics
1725
7x
func (s *QuestionService) GetQuestionsPaginated(ctx context.Context, userID, page, pageSize int, search, typeFilter, statusFilter string) (result0 []*QuestionWithStats, result1 int, err error) {
1726
7x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_questions_paginated", observability.AttributeUserID(userID), observability.AttributePage(page), observability.AttributePageSize(pageSize), observability.AttributeSearch(search), observability.AttributeTypeFilter(typeFilter), observability.AttributeStatusFilter(statusFilter))
1727
7x
    defer func() {
1728
7x
        if err != nil {
1729
            span.RecordError(err, trace.WithStackTrace(true))
1730
            span.SetStatus(codes.Error, err.Error())
1731
        }
1732
7x
        span.End()
1733
    }()
1734

1735
    // Build WHERE clause with filters using parameterized queries
1736
7x
    whereConditions := []string{"uq.user_id = $1"}
1737
7x
    args := []interface{}{userID}
1738
7x
    argCount := 1
1739
7x

1740
7x
    // Add search filter
1741
7x
    if search != "" {
1742
1x
        argCount++
1743
1x
        whereConditions = append(whereConditions, fmt.Sprintf("(q.content::text ILIKE $%d OR q.explanation ILIKE $%d)", argCount, argCount))
1744
1x
        args = append(args, "%"+search+"%")
1745
1x
    }
1746

1747
    // Add type filter
1748
7x
    if typeFilter != "" {
1749
1x
        argCount++
1750
1x
        whereConditions = append(whereConditions, fmt.Sprintf("q.type = $%d", argCount))
1751
1x
        args = append(args, typeFilter)
1752
1x
    }
1753

1754
    // Add status filter
1755
7x
    if statusFilter != "" {
1756
1x
        argCount++
1757
1x
        whereConditions = append(whereConditions, fmt.Sprintf("q.status = $%d", argCount))
1758
1x
        args = append(args, statusFilter)
1759
1x
    }
1760

1761
    // Join all conditions
1762
7x
    whereClause := "WHERE " + strings.Join(whereConditions, " AND ")
1763
7x

1764
7x
    // First get the total count with filters
1765
7x
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM questions q JOIN user_questions uq ON q.id = uq.question_id %s", whereClause)
1766
7x
    var totalCount int
1767
7x
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount)
1768
7x
    if err != nil {
1769
        return nil, 0, err
1770
    }
1771

1772
    // Calculate offset
1773
7x
    offset := (page - 1) * pageSize
1774
7x

1775
7x
    // Build main query with pagination
1776
7x
    query := fmt.Sprintf(`
1777
7x
        SELECT
1778
7x
            q.id, q.type, q.language, q.level, q.difficulty_score,
1779
7x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1780
7x
            q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
1781
7x
            COALESCE(SUM(CASE WHEN ur.is_correct = true THEN 1 ELSE 0 END), 0) as correct_count,
1782
7x
            COALESCE(SUM(CASE WHEN ur.is_correct = false THEN 1 ELSE 0 END), 0) as incorrect_count,
1783
7x
            COALESCE(COUNT(ur.id), 0) as total_responses,
1784
7x
            COALESCE(uq_stats.user_count, 0) as user_count
1785
7x
        FROM questions q
1786
7x
        JOIN user_questions uq ON q.id = uq.question_id
1787
7x
        LEFT JOIN user_responses ur ON q.id = ur.question_id
1788
7x
        LEFT JOIN (
1789
7x
            SELECT
1790
7x
                question_id,
1791
7x
                COUNT(*) as user_count
1792
7x
            FROM user_questions
1793
7x
            GROUP BY question_id
1794
7x
        ) uq_stats ON q.id = uq_stats.question_id
1795
7x
        %s
1796
7x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score,
1797
7x
            q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1798
7x
            q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
1799
7x
            uq_stats.user_count
1800
7x
        ORDER BY q.id DESC
1801
7x
        LIMIT $%d OFFSET $%d
1802
7x
    `, whereClause, argCount+1, argCount+2)
1803
7x

1804
7x
    // Add pagination parameters
1805
7x
    args = append(args, pageSize, offset)
1806
7x

1807
7x
    rows, err := s.db.QueryContext(ctx, query, args...)
1808
7x
    if err != nil {
1809
        return nil, 0, err
1810
    }
1811
7x
    defer func() {
1812
7x
        if err := rows.Close(); err != nil {
1813
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1814
        }
1815
    }()
1816

1817
7x
    var questions []*QuestionWithStats
1818
7x
    for rows.Next() {
1819
67x
        questionWithStats, err := s.scanQuestionWithStatsAndAllFieldsFromRows(rows)
1820
67x
        if err != nil {
1821
            return nil, 0, err
1822
        }
1823
67x
        questions = append(questions, questionWithStats)
1824
    }
1825

1826
7x
    if err = rows.Err(); err != nil {
1827
        return nil, 0, err
1828
    }
1829

1830
7x
    return questions, totalCount, nil
1831
}
1832

1833
// PRIORITY-BASED QUESTION SELECTION METHODS
1834

1835
// getAvailableQuestionsWithPriority retrieves available questions with priority scores and stats
1836
206x
func (s *QuestionService) getAvailableQuestionsWithPriority(ctx context.Context, userID int, language, level string, qType models.QuestionType, _ *models.UserLearningPreferences) (result0 []*QuestionWithStats, err error) {
1837
206x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_available_questions_with_priority", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
1838
206x
    defer func() {
1839
206x
        if err != nil {
1840
            span.RecordError(err, trace.WithStackTrace(true))
1841
            span.SetStatus(codes.Error, err.Error())
1842
        }
1843
206x
        span.End()
1844
    }()
1845
    // Build SQL query with priority scoring and stats
1846
206x
    query := `
1847
206x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1848
206x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
1849
206x
               COALESCE(qps.priority_score, 100.0) as priority_score,
1850
206x
               COALESCE(uq_stats.times_answered, 0) as times_answered,
1851
206x
               uq_stats.last_answered_at,
1852
206x
               COALESCE(stats.correct_count, 0) as correct_count,
1853
206x
               COALESCE(stats.incorrect_count, 0) as incorrect_count,
1854
206x
               COALESCE(stats.total_responses, 0) as total_responses,
1855
206x
               uqm.confidence_level
1856
206x
        FROM questions q
1857
206x
        JOIN user_questions uq ON q.id = uq.question_id
1858
206x
        LEFT JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
1859
206x
        LEFT JOIN (
1860
206x
            SELECT question_id,
1861
206x
                   COUNT(*) as times_answered,
1862
206x
                   MAX(created_at) as last_answered_at
1863
206x
            FROM user_responses
1864
206x
            WHERE user_id = $1
1865
206x
            GROUP BY question_id
1866
206x
        ) uq_stats ON q.id = uq_stats.question_id
1867
206x
        LEFT JOIN (
1868
206x
            SELECT
1869
206x
                question_id,
1870
206x
                COUNT(CASE WHEN is_correct = true THEN 1 END) as correct_count,
1871
206x
                COUNT(CASE WHEN is_correct = false THEN 1 END) as incorrect_count,
1872
206x
                COUNT(*) as total_responses
1873
206x
            FROM user_responses
1874
206x
            GROUP BY question_id
1875
206x
        ) stats ON q.id = stats.question_id
1876
206x
        LEFT JOIN user_question_metadata uqm ON q.id = uqm.question_id AND uqm.user_id = $1
1877
206x
        WHERE uq.user_id = $1
1878
206x
        AND q.language = $2
1879
206x
        AND q.level = $3
1880
206x
        AND q.type = $4
1881
206x
        AND q.status = 'active'
1882
206x
        AND q.id NOT IN (
1883
206x
            SELECT ur.question_id
1884
206x
            FROM user_responses ur
1885
206x
            WHERE ur.user_id = $1
1886
206x
              AND ur.created_at > NOW() - INTERVAL '1 hour'
1887
206x
        )
1888
206x
        -- Exclude questions where the user's last 3 responses were all correct within the last 90 days
1889
206x
        AND NOT EXISTS (
1890
206x
            SELECT 1 FROM (
1891
206x
                SELECT ur2.is_correct
1892
206x
                FROM user_responses ur2
1893
206x
                WHERE ur2.user_id = $1
1894
206x
                  AND ur2.question_id = q.id
1895
206x
                  AND ur2.created_at >= NOW() - INTERVAL '90 days'
1896
206x
                ORDER BY ur2.created_at DESC
1897
206x
                LIMIT 3
1898
206x
            ) recent_three
1899
206x
            WHERE (SELECT COUNT(*) FROM (
1900
206x
                SELECT 1 FROM (
1901
206x
                    SELECT ur3.is_correct
1902
206x
                    FROM user_responses ur3
1903
206x
                    WHERE ur3.user_id = $1
1904
206x
                      AND ur3.question_id = q.id
1905
206x
                      AND ur3.created_at >= NOW() - INTERVAL '90 days'
1906
206x
                    ORDER BY ur3.created_at DESC
1907
206x
                    LIMIT 3
1908
206x
                ) t WHERE t.is_correct = TRUE
1909
206x
            ) c) = 3
1910
206x
        )
1911
206x
        -- Exclude questions the user explicitly marked as known with max confidence (5)
1912
206x
        -- within the last 60 days (approx. 2 months)
1913
206x
        AND NOT EXISTS (
1914
206x
            SELECT 1 FROM user_question_metadata uqm2
1915
206x
            WHERE uqm2.user_id = $1
1916
206x
              AND uqm2.question_id = q.id
1917
206x
              AND uqm2.marked_as_known = TRUE
1918
206x
              AND uqm2.confidence_level = 5
1919
206x
              AND uqm2.marked_as_known_at >= NOW() - INTERVAL '60 days'
1920
206x
        )
1921
206x
        ORDER BY priority_score DESC, RANDOM()
1922
206x
        LIMIT 50
1923
206x
    `
1924
206x

1925
206x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, qType)
1926
206x
    if err != nil {
1927
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to query questions: %w", err)
1928
    }
1929
206x
    defer func() {
1930
206x
        if err := rows.Close(); err != nil {
1931
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
1932
        }
1933
    }()
1934

1935
206x
    var questions []*QuestionWithStats
1936
206x
    for rows.Next() {
1937
2011x
        questionWithStats, err := s.scanQuestionWithPriorityAndStatsFromRows(rows)
1938
2011x
        if err != nil {
1939
            s.logger.Error(ctx, "Error scanning question", err, map[string]interface{}{})
1940
            continue // Skip malformed rows
1941
        }
1942
2011x
        questions = append(questions, questionWithStats)
1943
    }
1944

1945
206x
    return questions, nil
1946
}
1947

1948
// getAvailableQuestionsForDailyWithPriority applies daily-specific eligibility:
1949
// exclude questions answered correctly within the last 2 days for the user.
1950
360x
func (s *QuestionService) getAvailableQuestionsForDailyWithPriority(ctx context.Context, userID int, language, level string, qType models.QuestionType, _ *models.UserLearningPreferences) (result0 []*QuestionWithStats, err error) {
1951
360x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_available_questions_for_daily_with_priority", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
1952
360x
    defer func() {
1953
360x
        if err != nil {
1954
            span.RecordError(err, trace.WithStackTrace(true))
1955
            span.SetStatus(codes.Error, err.Error())
1956
        }
1957
360x
        span.End()
1958
    }()
1959
360x
    avoidDays := s.getDailyRepeatAvoidDays()
1960
360x
    query := `
1961
360x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
1962
360x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
1963
360x
               COALESCE(qps.priority_score, 100.0) as priority_score,
1964
360x
               COALESCE(uq_stats.times_answered, 0) as times_answered,
1965
360x
               uq_stats.last_answered_at,
1966
360x
               COALESCE(stats.correct_count, 0) as correct_count,
1967
360x
               COALESCE(stats.incorrect_count, 0) as incorrect_count,
1968
360x
               COALESCE(stats.total_responses, 0) as total_responses,
1969
360x
               uqm.confidence_level
1970
360x
        FROM questions q
1971
360x
        JOIN user_questions uq ON q.id = uq.question_id
1972
360x
        LEFT JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
1973
360x
        LEFT JOIN (
1974
360x
            SELECT question_id,
1975
360x
                   COUNT(*) as times_answered,
1976
360x
                   MAX(created_at) as last_answered_at
1977
360x
            FROM user_responses
1978
360x
            WHERE user_id = $1
1979
360x
            GROUP BY question_id
1980
360x
        ) uq_stats ON q.id = uq_stats.question_id
1981
360x
        LEFT JOIN (
1982
360x
            SELECT
1983
360x
                question_id,
1984
360x
                COUNT(CASE WHEN is_correct = true THEN 1 END) as correct_count,
1985
360x
                COUNT(CASE WHEN is_correct = false THEN 1 END) as incorrect_count,
1986
360x
                COUNT(*) as total_responses
1987
360x
            FROM user_responses
1988
360x
            GROUP BY question_id
1989
360x
        ) stats ON q.id = stats.question_id
1990
360x
        LEFT JOIN user_question_metadata uqm ON q.id = uqm.question_id AND uqm.user_id = $1
1991
360x
        WHERE uq.user_id = $1
1992
360x
        AND q.language = $2
1993
360x
        AND q.level = $3
1994
360x
        AND q.type = $4
1995
360x
        AND q.status = 'active'
1996
360x
        AND NOT EXISTS (
1997
360x
            SELECT 1
1998
360x
            FROM user_responses ur
1999
360x
            WHERE ur.user_id = $1
2000
360x
              AND ur.question_id = q.id
2001
360x
              AND ur.is_correct = TRUE
2002
360x
              AND ur.created_at >= NOW() - ($5 || ' days')::interval
2003
360x
        )
2004
360x
        -- Exclude questions the user marked as known with confidence 5 within last 60 days
2005
360x
        AND NOT EXISTS (
2006
360x
            SELECT 1 FROM user_question_metadata uqm2
2007
360x
            WHERE uqm2.user_id = $1
2008
360x
              AND uqm2.question_id = q.id
2009
360x
              AND uqm2.marked_as_known = TRUE
2010
360x
              AND uqm2.confidence_level = 5
2011
360x
              AND uqm2.marked_as_known_at >= NOW() - INTERVAL '60 days'
2012
360x
        )
2013
360x
        ORDER BY priority_score DESC, RANDOM()
2014
360x
        LIMIT 50
2015
360x
    `
2016
360x

2017
360x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, qType, avoidDays)
2018
360x
    if err != nil {
2019
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to query questions (daily): %w", err)
2020
    }
2021
360x
    defer func() {
2022
360x
        if err := rows.Close(); err != nil {
2023
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2024
        }
2025
    }()
2026

2027
360x
    var questions []*QuestionWithStats
2028
360x
    for rows.Next() {
2029
1420x
        questionWithStats, err := s.scanQuestionWithPriorityAndStatsFromRows(rows)
2030
1420x
        if err != nil {
2031
            s.logger.Error(ctx, "Error scanning question (daily)", err, map[string]interface{}{})
2032
            continue
2033
        }
2034
1420x
        questions = append(questions, questionWithStats)
2035
    }
2036

2037
360x
    return questions, nil
2038
}
2039

2040
// selectQuestionWithWeightedRandomness selects a question using weighted random selection
2041
921x
func (s *QuestionService) selectQuestionWithWeightedRandomness(questions []*QuestionWithStats) (result0 *QuestionWithStats, err error) {
2042
921x
    if len(questions) == 0 {
2043
        return nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "no questions available")
2044
    }
2045

2046
    // Use weighted random selection based on usage count (lower = higher priority)
2047
921x
    totalWeight := 0.0
2048
921x
    for _, q := range questions {
2049
5486x
        // Prefer per-user times answered when available
2050
5486x
        usageCount := q.TotalResponses
2051
5486x
        if q.TimesAnswered >= 0 {
2052
5486x
            usageCount = q.TimesAnswered
2053
5486x
        }
2054
        // Lower usage count = higher weight
2055
5486x
        weight := 1.0 / (float64(usageCount) + 1.0)
2056
5486x
        totalWeight += weight
2057
    }
2058

2059
    // Handle edge case where all questions have zero weight or floating-point precision issues
2060
921x
    if totalWeight <= 0 {
2061
        // If all questions have equal weight (e.g., all TotalResponses = 0), use simple random selection
2062
        return questions[rand.Intn(len(questions))], nil
2063
    }
2064

2065
921x
    target := rand.Float64() * totalWeight
2066
921x
    currentWeight := 0.0
2067
921x

2068
921x
    for _, q := range questions {
2069
3204x
        usageCount := q.TotalResponses
2070
3204x
        if q.TimesAnswered >= 0 {
2071
3204x
            usageCount = q.TimesAnswered
2072
3204x
        }
2073
3204x
        weight := 1.0 / (float64(usageCount) + 1.0)
2074
3204x
        currentWeight += weight
2075
3204x
        if currentWeight >= target {
2076
921x
            return q, nil
2077
921x
        }
2078
    }
2079

2080
    // Fallback: if we reach the end without selecting (due to floating-point precision),
2081
    // return the last question or a random one
2082
    if len(questions) > 0 {
2083
        return questions[len(questions)-1], nil
2084
    }
2085

2086
    return nil, contextutils.WrapError(contextutils.ErrInternalError, "failed to select question with weighted randomness")
2087
}
2088

2089
// selectQuestionWithFreshnessRatio selects a question based on freshness ratio
2090
921x
func (s *QuestionService) selectQuestionWithFreshnessRatio(questions []*QuestionWithStats, freshnessRatio float64) (result0 *QuestionWithStats, err error) {
2091
921x
    if len(questions) == 0 {
2092
        return nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "no questions available")
2093
    }
2094

2095
    // Separate fresh and review questions based on total responses
2096
921x
    var freshQuestions []*QuestionWithStats
2097
921x
    var reviewQuestions []*QuestionWithStats
2098
921x

2099
921x
    for _, q := range questions {
2100
6436x
        // Consider fresh relative to this user (TimesAnswered==0). Fall back to TotalResponses if TimesAnswered not set.
2101
6436x
        isFresh := false
2102
6436x
        if q.TimesAnswered >= 0 {
2103
6436x
            isFresh = q.TimesAnswered == 0
2104
6436x
        } else {
2105
            isFresh = q.TotalResponses == 0
2106
        }
2107
6436x
        if isFresh {
2108
4834x
            freshQuestions = append(freshQuestions, q)
2109
4834x
        } else {
2110
1602x
            reviewQuestions = append(reviewQuestions, q)
2111
1602x
        }
2112
    }
2113

2114
    // Use probabilistic selection based on the freshness ratio
2115
921x
    var selectedQuestions []*QuestionWithStats
2116
921x
    if len(freshQuestions) > 0 && len(reviewQuestions) > 0 {
2117
202x
        // Both categories available - use probabilistic selection
2118
202x
        if rand.Float64() < freshnessRatio {
2119
92x
            selectedQuestions = freshQuestions
2120
92x
        } else {
2121
110x
            selectedQuestions = reviewQuestions
2122
110x
        }
2123
719x
    } else if len(freshQuestions) > 0 {
2124
719x
        // Only fresh questions available
2125
719x
        selectedQuestions = freshQuestions
2126
719x
    } else if len(reviewQuestions) > 0 {
2127
        // Only review questions available
2128
        selectedQuestions = reviewQuestions
2129
    } else {
2130
        // Fallback to all questions if no separation possible
2131
        selectedQuestions = questions
2132
    }
2133

2134
921x
    if len(selectedQuestions) == 0 {
2135
        return nil, contextutils.WrapError(contextutils.ErrRecordNotFound, "no questions available after freshness filtering")
2136
    }
2137

2138
    // Use weighted random selection within the chosen category
2139
921x
    result, err := s.selectQuestionWithWeightedRandomness(selectedQuestions)
2140
921x
    if err != nil {
2141
        // Log debug info about the selection failure
2142
        s.logger.Warn(context.Background(), "selectQuestionWithWeightedRandomness failed", map[string]interface{}{
2143
            "total_questions":        len(questions),
2144
            "fresh_questions":        len(freshQuestions),
2145
            "review_questions":       len(reviewQuestions),
2146
            "selected_category_size": len(selectedQuestions),
2147
            "freshness_ratio":        freshnessRatio,
2148
            "error":                  err.Error(),
2149
        })
2150
    }
2151
921x
    return result, err
2152
}
2153

2154
// GetUserQuestionCount returns the total number of questions available for a user
2155
3x
func (s *QuestionService) GetUserQuestionCount(ctx context.Context, userID int) (result0 int, err error) {
2156
3x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_user_question_count", observability.AttributeUserID(userID))
2157
3x
    defer func() {
2158
3x
        if err != nil {
2159
            span.RecordError(err, trace.WithStackTrace(true))
2160
            span.SetStatus(codes.Error, err.Error())
2161
        }
2162
3x
        span.End()
2163
    }()
2164
3x
    query := `
2165
3x
        SELECT COUNT(DISTINCT q.id)
2166
3x
        FROM questions q
2167
3x
        JOIN user_questions uq ON q.id = uq.question_id
2168
3x
        WHERE uq.user_id = $1 AND q.status = 'active'
2169
3x
    `
2170
3x

2171
3x
    var count int
2172
3x
    err = s.db.QueryRowContext(ctx, query, userID).Scan(&count)
2173
3x
    if err != nil {
2174
        return 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get user question count: %w", err)
2175
    }
2176
3x
    return count, nil
2177
}
2178

2179
// GetUserResponseCount returns the total number of responses for a user
2180
3x
func (s *QuestionService) GetUserResponseCount(ctx context.Context, userID int) (result0 int, err error) {
2181
3x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_user_response_count", observability.AttributeUserID(userID))
2182
3x
    defer func() {
2183
3x
        if err != nil {
2184
            span.RecordError(err, trace.WithStackTrace(true))
2185
            span.SetStatus(codes.Error, err.Error())
2186
        }
2187
3x
        span.End()
2188
    }()
2189
3x
    query := `SELECT COUNT(*) FROM user_responses WHERE user_id = $1`
2190
3x

2191
3x
    var count int
2192
3x
    err = s.db.QueryRowContext(ctx, query, userID).Scan(&count)
2193
3x
    if err != nil {
2194
        return 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get user response count: %w", err)
2195
    }
2196
3x
    return count, nil
2197
}
2198

2199
// GetUsersForQuestion returns the users assigned to a question, up to 5 users, and the total count
2200
func (s *QuestionService) GetUsersForQuestion(ctx context.Context, questionID int) (result0 []*models.User, result1 int, err error) {
2201
    ctx, span := observability.TraceQuestionFunction(ctx, "get_users_for_question", observability.AttributeQuestionID(questionID))
2202
    defer func() {
2203
        if err != nil {
2204
            span.RecordError(err, trace.WithStackTrace(true))
2205
            span.SetStatus(codes.Error, err.Error())
2206
        }
2207
        span.End()
2208
    }()
2209

2210
    // First get the total count
2211
    countQuery := `SELECT COUNT(*) FROM user_questions WHERE question_id = $1`
2212
    var totalCount int
2213
    err = s.db.QueryRowContext(ctx, countQuery, questionID).Scan(&totalCount)
2214
    if err != nil {
2215
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get user count for question: %w", err)
2216
    }
2217

2218
    // Then get up to 5 users
2219
    usersQuery := `
2220
        SELECT u.id, u.username, u.email, u.timezone, u.password_hash, u.last_active,
2221
               u.preferred_language, u.current_level, u.ai_provider, u.ai_model,
2222
               u.ai_enabled, u.ai_api_key, u.created_at, u.updated_at
2223
        FROM users u
2224
        JOIN user_questions uq ON u.id = uq.user_id
2225
        WHERE uq.question_id = $1
2226
        ORDER BY u.username
2227
        LIMIT 5
2228
    `
2229

2230
    rows, err := s.db.QueryContext(ctx, usersQuery, questionID)
2231
    if err != nil {
2232
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get users for question: %w", err)
2233
    }
2234
    defer func() {
2235
        if err := rows.Close(); err != nil {
2236
            s.logger.Warn(ctx, "Failed to close rows", map[string]interface{}{"error": err.Error()})
2237
        }
2238
    }()
2239

2240
    var users []*models.User
2241
    for rows.Next() {
2242
        user := &models.User{}
2243
        err = rows.Scan(
2244
            &user.ID,
2245
            &user.Username,
2246
            &user.Email,
2247
            &user.Timezone,
2248
            &user.PasswordHash,
2249
            &user.LastActive,
2250
            &user.PreferredLanguage,
2251
            &user.CurrentLevel,
2252
            &user.AIProvider,
2253
            &user.AIModel,
2254
            &user.AIEnabled,
2255
            &user.AIAPIKey,
2256
            &user.CreatedAt,
2257
            &user.UpdatedAt,
2258
        )
2259
        if err != nil {
2260
            return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to scan user: %w", err)
2261
        }
2262
        users = append(users, user)
2263
    }
2264

2265
    if err = rows.Err(); err != nil {
2266
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "error iterating users: %w", err)
2267
    }
2268

2269
    // Ensure we always return an empty slice instead of nil
2270
    if users == nil {
2271
        users = make([]*models.User, 0)
2272
    }
2273

2274
    return users, totalCount, nil
2275
}
2276

2277
// Helper: scan a *sql.Row into a QuestionWithStats (for single-row queries)
2278
38x
func (s *QuestionService) scanQuestionWithPriorityAndStatsFromRow(row *sql.Row) (result0 *QuestionWithStats, err error) {
2279
38x
    questionWithStats := &QuestionWithStats{
2280
38x
        Question: &models.Question{},
2281
38x
    }
2282
38x
    var contentJSON string
2283
38x
    var priorityScore float64
2284
38x
    var timesAnswered int
2285
38x
    var lastAnsweredAt sql.NullTime
2286
38x

2287
38x
    err = row.Scan(
2288
38x
        &questionWithStats.ID,
2289
38x
        &questionWithStats.Type,
2290
38x
        &questionWithStats.Language,
2291
38x
        &questionWithStats.Level,
2292
38x
        &questionWithStats.DifficultyScore,
2293
38x
        &contentJSON,
2294
38x
        &questionWithStats.CorrectAnswer,
2295
38x
        &questionWithStats.Explanation,
2296
38x
        &questionWithStats.CreatedAt,
2297
38x
        &questionWithStats.Status,
2298
38x
        &questionWithStats.TopicCategory,
2299
38x
        &questionWithStats.GrammarFocus,
2300
38x
        &questionWithStats.VocabularyDomain,
2301
38x
        &questionWithStats.Scenario,
2302
38x
        &questionWithStats.StyleModifier,
2303
38x
        &questionWithStats.DifficultyModifier,
2304
38x
        &questionWithStats.TimeContext,
2305
38x
        &priorityScore,
2306
38x
        &timesAnswered,
2307
38x
        &lastAnsweredAt,
2308
38x
        &questionWithStats.CorrectCount,
2309
38x
        &questionWithStats.IncorrectCount,
2310
38x
        &questionWithStats.TotalResponses,
2311
38x
    )
2312
38x
    if err != nil {
2313
31x
        return nil, err
2314
31x
    }
2315

2316
7x
    if err := questionWithStats.UnmarshalContentFromJSON(contentJSON); err != nil {
2317
        return nil, err
2318
    }
2319

2320
7x
    return questionWithStats, nil
2321
}
2322

2323
// GetRandomGlobalQuestionForUser finds a random question from the global pool for the given language, level, and type that is not already assigned to the user, assigns it, and returns it.
2324
38x
func (s *QuestionService) GetRandomGlobalQuestionForUser(ctx context.Context, userID int, language, level string, qType models.QuestionType) (result0 *QuestionWithStats, err error) {
2325
38x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_random_global_question_for_user", observability.AttributeUserID(userID), observability.AttributeLanguage(language), observability.AttributeLevel(level), observability.AttributeQuestionType(qType))
2326
38x
    defer func() {
2327
38x
        if err != nil {
2328
            span.RecordError(err, trace.WithStackTrace(true))
2329
            span.SetStatus(codes.Error, err.Error())
2330
        }
2331
38x
        span.End()
2332
    }()
2333

2334
38x
    query := `
2335
38x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
2336
38x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
2337
38x
               100.0 as priority_score, 0 as times_answered, NULL as last_answered_at, 0 as correct_count, 0 as incorrect_count, 0 as total_responses
2338
38x
        FROM questions q
2339
38x
        WHERE q.language = $1
2340
38x
          AND q.level = $2
2341
38x
        AND q.type = $3
2342
38x
          AND q.status = 'active'
2343
38x
          AND q.id NOT IN (
2344
38x
            SELECT uq.question_id
2345
38x
            FROM user_questions uq
2346
38x
            WHERE uq.user_id = $4
2347
38x
          )
2348
38x
          -- Exclude questions the user marked as known with confidence 5 within last 60 days
2349
38x
          AND NOT EXISTS (
2350
38x
            SELECT 1 FROM user_question_metadata uqm2
2351
38x
            WHERE uqm2.user_id = $4
2352
38x
              AND uqm2.question_id = q.id
2353
38x
              AND uqm2.marked_as_known = TRUE
2354
38x
              AND uqm2.confidence_level = 5
2355
38x
              AND uqm2.marked_as_known_at >= NOW() - INTERVAL '60 days'
2356
38x
          )
2357
38x
        ORDER BY RANDOM()
2358
38x
        LIMIT 1
2359
38x
    `
2360
38x

2361
38x
    row := s.db.QueryRowContext(ctx, query, language, level, qType, userID)
2362
38x
    questionWithStats, err := s.scanQuestionWithPriorityAndStatsFromRow(row)
2363
38x
    if err != nil {
2364
31x
        if errors.Is(err, sql.ErrNoRows) {
2365
31x
            return nil, nil // No global questions available
2366
31x
        }
2367
        return nil, err
2368
    }
2369

2370
    // Assign the question to the user
2371
7x
    err = s.AssignQuestionToUser(ctx, questionWithStats.ID, userID)
2372
7x
    if err != nil {
2373
        s.logger.Warn(ctx, "Failed to assign global question to user", map[string]interface{}{"question_id": questionWithStats.ID, "user_id": userID, "error": err.Error()})
2374
        // Still return the question, but log the error
2375
    }
2376

2377
7x
    return questionWithStats, nil
2378
}
2379

2380
// GetAllQuestionsPaginated returns all questions with pagination and filtering
2381
1x
func (s *QuestionService) GetAllQuestionsPaginated(ctx context.Context, page, pageSize int, search, typeFilter, statusFilter, languageFilter, levelFilter string, userID *int) (result0 []*QuestionWithStats, result1 int, err error) {
2382
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_all_questions_paginated")
2383
1x
    defer func() {
2384
1x
        if err != nil {
2385
            span.RecordError(err, trace.WithStackTrace(true))
2386
            span.SetStatus(codes.Error, err.Error())
2387
        }
2388
1x
        span.End()
2389
    }()
2390

2391
    // Build the base query
2392
1x
    baseQuery := `
2393
1x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
2394
1x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
2395
1x
               COALESCE(ur_stats.correct_count, 0) as correct_count,
2396
1x
               COALESCE(ur_stats.incorrect_count, 0) as incorrect_count,
2397
1x
               COALESCE(ur_stats.total_responses, 0) as total_responses,
2398
1x
               COALESCE(uq_stats.user_count, 0) as user_count
2399
1x
        FROM questions q
2400
1x
        LEFT JOIN (
2401
1x
            SELECT
2402
1x
                question_id,
2403
1x
                COUNT(CASE WHEN is_correct = true THEN 1 END) as correct_count,
2404
1x
                COUNT(CASE WHEN is_correct = false THEN 1 END) as incorrect_count,
2405
1x
                COUNT(*) as total_responses
2406
1x
            FROM user_responses
2407
1x
            GROUP BY question_id
2408
1x
        ) ur_stats ON q.id = ur_stats.question_id
2409
1x
        LEFT JOIN (
2410
1x
            SELECT
2411
1x
                question_id,
2412
1x
                COUNT(*) as user_count
2413
1x
            FROM user_questions
2414
1x
            GROUP BY question_id
2415
1x
        ) uq_stats ON q.id = uq_stats.question_id
2416
1x
        WHERE 1=1
2417
1x
    `
2418
1x

2419
1x
    // Build the count query
2420
1x
    countQuery := `
2421
1x
        SELECT COUNT(*)
2422
1x
        FROM questions q
2423
1x
        WHERE 1=1
2424
1x
    `
2425
1x

2426
1x
    var args []interface{}
2427
1x
    argIndex := 1
2428
1x

2429
1x
    // Add filters
2430
1x
    if search != "" {
2431
        searchCondition := ` AND (q.content::text ILIKE $` + strconv.Itoa(argIndex) + ` OR q.explanation ILIKE $` + strconv.Itoa(argIndex) + `)`
2432
        baseQuery += searchCondition
2433
        countQuery += searchCondition
2434
        args = append(args, "%"+search+"%")
2435
        argIndex++
2436
    }
2437

2438
1x
    if typeFilter != "" {
2439
        typeCondition := ` AND q.type = $` + strconv.Itoa(argIndex)
2440
        baseQuery += typeCondition
2441
        countQuery += typeCondition
2442
        args = append(args, typeFilter)
2443
        argIndex++
2444
    }
2445

2446
1x
    if statusFilter != "" {
2447
        statusCondition := ` AND q.status = $` + strconv.Itoa(argIndex)
2448
        baseQuery += statusCondition
2449
        countQuery += statusCondition
2450
        args = append(args, statusFilter)
2451
        argIndex++
2452
    }
2453

2454
1x
    if languageFilter != "" {
2455
1x
        languageCondition := ` AND q.language = $` + strconv.Itoa(argIndex)
2456
1x
        baseQuery += languageCondition
2457
1x
        countQuery += languageCondition
2458
1x
        args = append(args, languageFilter)
2459
1x
        argIndex++
2460
1x
    }
2461

2462
1x
    if levelFilter != "" {
2463
1x
        levelCondition := ` AND q.level = $` + strconv.Itoa(argIndex)
2464
1x
        baseQuery += levelCondition
2465
1x
        countQuery += levelCondition
2466
1x
        args = append(args, levelFilter)
2467
1x
        argIndex++
2468
1x
    }
2469

2470
1x
    if userID != nil {
2471
        userCondition := ` AND q.id IN (SELECT question_id FROM user_questions WHERE user_id = $` + strconv.Itoa(argIndex) + `)`
2472
        baseQuery += userCondition
2473
        countQuery += userCondition
2474
        args = append(args, *userID)
2475
        argIndex++
2476
    }
2477

2478
    // Get total count
2479
1x
    var total int
2480
1x
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total)
2481
1x
    if err != nil {
2482
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get total count: %w", err)
2483
    }
2484

2485
    // Add pagination
2486
1x
    offset := (page - 1) * pageSize
2487
1x
    baseQuery += ` ORDER BY q.created_at DESC LIMIT $` + strconv.Itoa(argIndex) + ` OFFSET $` + strconv.Itoa(argIndex+1)
2488
1x
    args = append(args, pageSize, offset)
2489
1x

2490
1x
    // Execute the main query
2491
1x
    rows, err := s.db.QueryContext(ctx, baseQuery, args...)
2492
1x
    if err != nil {
2493
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get questions: %w", err)
2494
    }
2495
1x
    defer func() {
2496
1x
        if closeErr := rows.Close(); closeErr != nil {
2497
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2498
        }
2499
    }()
2500

2501
1x
    var questions []*QuestionWithStats
2502
1x
    for rows.Next() {
2503
        question, err := s.scanQuestionWithStatsAndAllFieldsFromRows(rows)
2504
        if err != nil {
2505
            return nil, 0, err
2506
        }
2507
        questions = append(questions, question)
2508
    }
2509

2510
1x
    return questions, total, nil
2511
}
2512

2513
// GetReportedQuestionsPaginated returns reported questions with pagination and filtering
2514
13x
func (s *QuestionService) GetReportedQuestionsPaginated(ctx context.Context, page, pageSize int, search, typeFilter, languageFilter, levelFilter string) (result0 []*QuestionWithStats, result1 int, err error) {
2515
13x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_reported_questions_paginated")
2516
13x
    defer func() {
2517
13x
        if err != nil {
2518
            span.RecordError(err, trace.WithStackTrace(true))
2519
            span.SetStatus(codes.Error, err.Error())
2520
        }
2521
13x
        span.End()
2522
    }()
2523

2524
    // Validate pagination parameters
2525
13x
    if page < 1 {
2526
1x
        page = 1
2527
1x
    }
2528
13x
    if pageSize < 1 {
2529
1x
        pageSize = 10
2530
1x
    }
2531

2532
    // Build WHERE clause with filters using parameterized queries
2533
13x
    whereConditions := []string{"q.status = 'reported'"}
2534
13x
    args := []interface{}{}
2535
13x
    argCount := 0
2536
13x

2537
13x
    // Add search filter
2538
13x
    if search != "" {
2539
2x
        argCount++
2540
2x
        whereConditions = append(whereConditions, fmt.Sprintf("(q.content::text ILIKE $%d OR q.explanation ILIKE $%d)", argCount, argCount))
2541
2x
        args = append(args, "%"+search+"%")
2542
2x
    }
2543

2544
    // Add type filter
2545
13x
    if typeFilter != "" {
2546
2x
        argCount++
2547
2x
        whereConditions = append(whereConditions, fmt.Sprintf("q.type = $%d", argCount))
2548
2x
        args = append(args, typeFilter)
2549
2x
    }
2550

2551
    // Add language filter
2552
13x
    if languageFilter != "" {
2553
2x
        argCount++
2554
2x
        whereConditions = append(whereConditions, fmt.Sprintf("q.language = $%d", argCount))
2555
2x
        args = append(args, languageFilter)
2556
2x
    }
2557

2558
    // Add level filter
2559
13x
    if levelFilter != "" {
2560
2x
        argCount++
2561
2x
        whereConditions = append(whereConditions, fmt.Sprintf("q.level = $%d", argCount))
2562
2x
        args = append(args, levelFilter)
2563
2x
    }
2564

2565
    // Join all conditions
2566
13x
    whereClause := "WHERE " + strings.Join(whereConditions, " AND ")
2567
13x

2568
13x
    // Build the count query
2569
13x
    countQuery := fmt.Sprintf("SELECT COUNT(DISTINCT q.id) FROM questions q %s", whereClause)
2570
13x
    var total int
2571
13x
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total)
2572
13x
    if err != nil {
2573
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get total count: %w", err)
2574
    }
2575

2576
    // Calculate offset
2577
13x
    offset := (page - 1) * pageSize
2578
13x

2579
13x
    // Build main query with pagination
2580
13x
    query := fmt.Sprintf(`
2581
13x
        SELECT q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
2582
13x
               q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
2583
13x
               COALESCE(ur_stats.correct_count, 0) as correct_count,
2584
13x
               COALESCE(ur_stats.incorrect_count, 0) as incorrect_count,
2585
13x
               COALESCE(ur_stats.total_responses, 0) as total_responses,
2586
13x
               STRING_AGG(DISTINCT u.username, ', ') as reporters,
2587
13x
               STRING_AGG(DISTINCT qr.report_reason, ' | ') as report_reasons
2588
13x
        FROM questions q
2589
13x
        LEFT JOIN (
2590
13x
            SELECT
2591
13x
                question_id,
2592
13x
                COUNT(CASE WHEN is_correct = true THEN 1 END) as correct_count,
2593
13x
                COUNT(CASE WHEN is_correct = false THEN 1 END) as incorrect_count,
2594
13x
                COUNT(*) as total_responses
2595
13x
            FROM user_responses
2596
13x
            GROUP BY question_id
2597
13x
        ) ur_stats ON q.id = ur_stats.question_id
2598
13x
        LEFT JOIN question_reports qr ON q.id = qr.question_id
2599
13x
        LEFT JOIN users u ON qr.reported_by_user_id = u.id
2600
13x
        %s
2601
13x
        GROUP BY q.id, q.type, q.language, q.level, q.difficulty_score, q.content, q.correct_answer, q.explanation, q.created_at, q.status,
2602
13x
                 q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario, q.style_modifier, q.difficulty_modifier, q.time_context,
2603
13x
                 ur_stats.correct_count, ur_stats.incorrect_count, ur_stats.total_responses
2604
13x
        ORDER BY q.created_at DESC
2605
13x
        LIMIT $%d OFFSET $%d
2606
13x
    `, whereClause, argCount+1, argCount+2)
2607
13x

2608
13x
    // Add pagination parameters
2609
13x
    args = append(args, pageSize, offset)
2610
13x

2611
13x
    // Execute the main query
2612
13x
    rows, err := s.db.QueryContext(ctx, query, args...)
2613
13x
    if err != nil {
2614
        return nil, 0, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get reported questions: %w", err)
2615
    }
2616
13x
    defer func() {
2617
13x
        if closeErr := rows.Close(); closeErr != nil {
2618
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2619
        }
2620
    }()
2621

2622
13x
    var questions []*QuestionWithStats
2623
13x
    for rows.Next() {
2624
11x
        question, err := s.scanQuestionWithStatsAndReportersFromRows(rows)
2625
11x
        if err != nil {
2626
            return nil, 0, err
2627
        }
2628
11x
        questions = append(questions, question)
2629
    }
2630

2631
13x
    return questions, total, nil
2632
}
2633

2634
// GetReportedQuestionsStats returns statistics about reported questions
2635
1x
func (s *QuestionService) GetReportedQuestionsStats(ctx context.Context) (result0 map[string]interface{}, err error) {
2636
1x
    ctx, span := observability.TraceQuestionFunction(ctx, "get_reported_questions_stats")
2637
1x
    defer func() {
2638
1x
        if err != nil {
2639
            span.RecordError(err, trace.WithStackTrace(true))
2640
            span.SetStatus(codes.Error, err.Error())
2641
        }
2642
1x
        span.End()
2643
    }()
2644

2645
1x
    stats := make(map[string]interface{})
2646
1x

2647
1x
    // Get total reported questions
2648
1x
    var totalReported int
2649
1x
    err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM questions WHERE status = 'reported'`).Scan(&totalReported)
2650
1x
    if err != nil {
2651
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get total reported questions: %w", err)
2652
    }
2653
1x
    stats["total_reported"] = totalReported
2654
1x

2655
1x
    // Get reported questions by type
2656
1x
    rows, err := s.db.QueryContext(ctx, `
2657
1x
        SELECT type, COUNT(*) as count
2658
1x
        FROM questions
2659
1x
        WHERE status = 'reported'
2660
1x
        GROUP BY type
2661
1x
        ORDER BY count DESC
2662
1x
    `)
2663
1x
    if err != nil {
2664
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get reported questions by type: %w", err)
2665
    }
2666
1x
    defer func() {
2667
1x
        if closeErr := rows.Close(); closeErr != nil {
2668
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2669
        }
2670
    }()
2671

2672
1x
    reportedByType := make(map[string]int)
2673
1x
    for rows.Next() {
2674
2x
        var questionType string
2675
2x
        var count int
2676
2x
        if err := rows.Scan(&questionType, &count); err != nil {
2677
            return nil, err
2678
        }
2679
2x
        reportedByType[questionType] = count
2680
    }
2681
1x
    stats["reported_by_type"] = reportedByType
2682
1x

2683
1x
    // Get reported questions by level
2684
1x
    rows, err = s.db.QueryContext(ctx, `
2685
1x
        SELECT level, COUNT(*) as count
2686
1x
        FROM questions
2687
1x
        WHERE status = 'reported'
2688
1x
        GROUP BY level
2689
1x
        ORDER BY count DESC
2690
1x
    `)
2691
1x
    if err != nil {
2692
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get reported questions by level: %w", err)
2693
    }
2694
1x
    defer func() {
2695
1x
        if closeErr := rows.Close(); closeErr != nil {
2696
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2697
        }
2698
    }()
2699

2700
1x
    reportedByLevel := make(map[string]int)
2701
1x
    for rows.Next() {
2702
3x
        var level string
2703
3x
        var count int
2704
3x
        if err := rows.Scan(&level, &count); err != nil {
2705
            return nil, err
2706
        }
2707
3x
        reportedByLevel[level] = count
2708
    }
2709
1x
    stats["reported_by_level"] = reportedByLevel
2710
1x

2711
1x
    // Get reported questions by language
2712
1x
    rows, err = s.db.QueryContext(ctx, `
2713
1x
        SELECT language, COUNT(*) as count
2714
1x
        FROM questions
2715
1x
        WHERE status = 'reported'
2716
1x
        GROUP BY language
2717
1x
        ORDER BY count DESC
2718
1x
    `)
2719
1x
    if err != nil {
2720
        return nil, contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "failed to get reported questions by language: %w", err)
2721
    }
2722
1x
    defer func() {
2723
1x
        if closeErr := rows.Close(); closeErr != nil {
2724
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
2725
        }
2726
    }()
2727

2728
1x
    reportedByLanguage := make(map[string]int)
2729
1x
    for rows.Next() {
2730
2x
        var language string
2731
2x
        var count int
2732
2x
        if err := rows.Scan(&language, &count); err != nil {
2733
            return nil, err
2734
        }
2735
2x
        reportedByLanguage[language] = count
2736
    }
2737
1x
    stats["reported_by_language"] = reportedByLanguage
2738
1x

2739
1x
    return stats, nil
2740
}
2741

2742
// AssignUsersToQuestion assigns multiple users to a question
2743
func (s *QuestionService) AssignUsersToQuestion(ctx context.Context, questionID int, userIDs []int) (err error) {
2744
    ctx, span := observability.TraceQuestionFunction(ctx, "assign_users_to_question", observability.AttributeQuestionID(questionID))
2745
    defer func() {
2746
        if err != nil {
2747
            span.RecordError(err, trace.WithStackTrace(true))
2748
            span.SetStatus(codes.Error, err.Error())
2749
        }
2750
        span.End()
2751
    }()
2752

2753
    // Start a transaction
2754
    tx, err := s.db.BeginTx(ctx, nil)
2755
    if err != nil {
2756
        return contextutils.WrapError(err, "failed to begin transaction")
2757
    }
2758
    defer func() {
2759
        if err != nil {
2760
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
2761
                s.logger.Warn(ctx, "Failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
2762
            }
2763
        }
2764
    }()
2765

2766
    // Prepare the insert statement
2767
    stmt, err := tx.PrepareContext(ctx, `
2768
        INSERT INTO user_questions (user_id, question_id, created_at)
2769
        VALUES ($1, $2, NOW())
2770
        ON CONFLICT (user_id, question_id) DO NOTHING
2771
    `)
2772
    if err != nil {
2773
        return contextutils.WrapError(err, "failed to prepare insert statement")
2774
    }
2775
    defer func() {
2776
        if closeErr := stmt.Close(); closeErr != nil {
2777
            s.logger.Warn(ctx, "Warning: failed to close statement", map[string]interface{}{"error": closeErr.Error()})
2778
        }
2779
    }()
2780

2781
    // Insert each user-question mapping
2782
    for _, userID := range userIDs {
2783
        _, err = stmt.ExecContext(ctx, userID, questionID)
2784
        if err != nil {
2785
            return contextutils.WrapErrorf(err, "failed to assign user %d to question %d", userID, questionID)
2786
        }
2787
    }
2788

2789
    // Commit the transaction
2790
    err = tx.Commit()
2791
    if err != nil {
2792
        return contextutils.WrapError(err, "failed to commit transaction")
2793
    }
2794

2795
    return nil
2796
}
2797

2798
// UnassignUsersFromQuestion removes multiple users from a question
2799
func (s *QuestionService) UnassignUsersFromQuestion(ctx context.Context, questionID int, userIDs []int) (err error) {
2800
    ctx, span := observability.TraceQuestionFunction(ctx, "unassign_users_from_question", observability.AttributeQuestionID(questionID))
2801
    defer func() {
2802
        if err != nil {
2803
            span.RecordError(err, trace.WithStackTrace(true))
2804
            span.SetStatus(codes.Error, err.Error())
2805
        }
2806
        span.End()
2807
    }()
2808

2809
    // Start a transaction
2810
    tx, err := s.db.BeginTx(ctx, nil)
2811
    if err != nil {
2812
        return contextutils.WrapError(err, "failed to begin transaction")
2813
    }
2814
    defer func() {
2815
        if err != nil {
2816
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
2817
                s.logger.Warn(ctx, "Failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
2818
            }
2819
        }
2820
    }()
2821

2822
    // Prepare the delete statement
2823
    stmt, err := tx.PrepareContext(ctx, `
2824
        DELETE FROM user_questions
2825
        WHERE user_id = $1 AND question_id = $2
2826
    `)
2827
    if err != nil {
2828
        return contextutils.WrapError(err, "failed to prepare delete statement")
2829
    }
2830
    defer func() {
2831
        if closeErr := stmt.Close(); closeErr != nil {
2832
            s.logger.Warn(ctx, "Warning: failed to close statement", map[string]interface{}{"error": closeErr.Error()})
2833
        }
2834
    }()
2835

2836
    // Delete each user-question mapping
2837
    for _, userID := range userIDs {
2838
        _, err = stmt.ExecContext(ctx, userID, questionID)
2839
        if err != nil {
2840
            return contextutils.WrapErrorf(err, "failed to unassign user %d from question %d", userID, questionID)
2841
        }
2842
    }
2843

2844
    // Commit the transaction
2845
    err = tx.Commit()
2846
    if err != nil {
2847
        return contextutils.WrapError(err, "failed to commit transaction")
2848
    }
2849

2850
    return nil
2851
}
2852

2853
// DB returns the underlying *sql.DB instance
2854
func (s *QuestionService) DB() *sql.DB {
2855
    return s.db
2856
}
2857


			
quizapp internal services worker_service.go
63.9%
Statements
23/36
1
// Package services provides business logic services for the quiz application.
2
package services
3

4
import (
5
    "context"
6
    "database/sql"
7
    "time"
8

9
    "quizapp/internal/config"
10
    "quizapp/internal/models"
11
    "quizapp/internal/observability"
12
    contextutils "quizapp/internal/utils"
13

14
    "go.opentelemetry.io/otel"
15
    "go.opentelemetry.io/otel/attribute"
16
    "go.opentelemetry.io/otel/trace"
17
)
18

19
// TestEmailService implements the Mailer interface for testing purposes
20
// It doesn't actually send emails but logs the operations and records them in the database
21
type TestEmailService struct {
22
    cfg    *config.Config
23
    logger *observability.Logger
24
    db     *sql.DB
25
}
26

27
// NewTestEmailService creates a new TestEmailService instance
28
6x
func NewTestEmailService(cfg *config.Config, logger *observability.Logger) *TestEmailService {
29
6x
    return &TestEmailService{
30
6x
        cfg:    cfg,
31
6x
        logger: logger,
32
6x
    }
33
6x
}
34

35
// NewTestEmailServiceWithDB creates a new TestEmailService instance with database connection
36
1x
func NewTestEmailServiceWithDB(cfg *config.Config, logger *observability.Logger, db *sql.DB) *TestEmailService {
37
1x
    return &TestEmailService{
38
1x
        cfg:    cfg,
39
1x
        logger: logger,
40
1x
        db:     db,
41
1x
    }
42
1x
}
43

44
// SendDailyReminder sends a daily reminder email to a user (test mode - just logs)
45
2x
func (e *TestEmailService) SendDailyReminder(ctx context.Context, user *models.User) error {
46
2x
    ctx, span := otel.Tracer("test-email-service").Start(ctx, "SendDailyReminder",
47
2x
        trace.WithAttributes(
48
2x
            attribute.Int("user.id", user.ID),
49
2x
            attribute.String("user.email", user.Email.String),
50
2x
        ),
51
2x
    )
52
2x
    defer span.End()
53
2x

54
2x
    if !user.Email.Valid || user.Email.String == "" {
55
        e.logger.Warn(ctx, "User has no email address, skipping daily reminder", map[string]interface{}{
56
            "user_id": user.ID,
57
        })
58
        return nil
59
    }
60

61
    // Generate email data (same as real service) - not used in test mode but kept for consistency
62
2x
    _ = map[string]interface{}{
63
2x
        "Username":       user.Username,
64
2x
        "QuizAppURL":     e.cfg.Server.AppBaseURL,
65
2x
        "CurrentDate":    time.Now().Format("January 2, 2006"),
66
2x
        "DailyGoal":      10,
67
2x
        "StreakDays":     5,
68
2x
        "TotalQuestions": 150,
69
2x
        "Level":          "B1",
70
2x
        "Language":       "Italian",
71
2x
    }
72
2x

73
2x
    // Log the email operation instead of sending. Use the same subject as the
74
2x
    // real service to avoid confusion, but do NOT record a second entry in the
75
2x
    // database here â recording is handled by caller to ensure a single source
76
2x
    // of truth for sent notifications.
77
2x
    e.logger.Info(ctx, "TEST MODE: Would send daily reminder email", map[string]interface{}{
78
2x
        "user_id":   user.ID,
79
2x
        "email":     user.Email.String,
80
2x
        "template":  "daily_reminder",
81
2x
        "subject":   "Time for your daily quiz! ð",
82
2x
        "test_mode": true,
83
2x
    })
84
2x

85
2x
    return nil
86
}
87

88
// SendEmail sends a generic email with the given parameters (test mode - just logs)
89
1x
func (e *TestEmailService) SendEmail(ctx context.Context, to, subject, templateName string, data map[string]interface{}) error {
90
1x
    ctx, span := otel.Tracer("test-email-service").Start(ctx, "SendEmail",
91
1x
        trace.WithAttributes(
92
1x
            attribute.String("email.to", to),
93
1x
            attribute.String("email.subject", subject),
94
1x
            attribute.String("email.template", templateName),
95
1x
        ),
96
1x
    )
97
1x
    defer span.End()
98
1x

99
1x
    // Log the email operation instead of sending
100
1x
    e.logger.Info(ctx, "TEST MODE: Would send email", map[string]interface{}{
101
1x
        "to":        to,
102
1x
        "subject":   subject,
103
1x
        "template":  templateName,
104
1x
        "test_mode": true,
105
1x
        "data_keys": getMapKeys(data),
106
1x
    })
107
1x

108
1x
    // Record the notification in the database if we have a DB connection
109
1x
    if e.db != nil {
110
        // For test emails, we don't have a user ID, so we'll use 0
111
        err := e.RecordSentNotification(ctx, 0, "test_email", subject, templateName, "sent", "")
112
        if err != nil {
113
            e.logger.Error(ctx, "Failed to record test notification", err, map[string]interface{}{
114
                "to":       to,
115
                "template": templateName,
116
            })
117
        }
118
    }
119

120
1x
    return nil
121
}
122

123
// RecordSentNotification records a sent notification in the database
124
1x
func (e *TestEmailService) RecordSentNotification(ctx context.Context, userID int, notificationType, subject, templateName, status, errorMessage string) error {
125
1x
    ctx, span := otel.Tracer("test-email-service").Start(ctx, "RecordSentNotification",
126
1x
        trace.WithAttributes(
127
1x
            attribute.Int("user.id", userID),
128
1x
            attribute.String("notification.type", notificationType),
129
1x
            attribute.String("notification.status", status),
130
1x
        ),
131
1x
    )
132
1x
    defer span.End()
133
1x

134
1x
    if e.db == nil {
135
1x
        e.logger.Warn(ctx, "No database connection available for recording notification", map[string]interface{}{
136
1x
            "user_id":           userID,
137
1x
            "notification_type": notificationType,
138
1x
        })
139
1x
        return nil
140
1x
    }
141

142
    query := `
143
        INSERT INTO sent_notifications (user_id, notification_type, subject, template_name, sent_at, status, error_message)
144
        VALUES ($1, $2, $3, $4, $5, $6, $7)
145
    `
146

147
    _, err := e.db.ExecContext(ctx, query, userID, notificationType, subject, templateName, time.Now(), status, errorMessage)
148
    if err != nil {
149
        span.RecordError(err)
150
        e.logger.Error(ctx, "Failed to record sent notification", err, map[string]interface{}{
151
            "user_id":           userID,
152
            "notification_type": notificationType,
153
            "status":            status,
154
        })
155
        return contextutils.WrapError(err, "failed to record sent notification")
156
    }
157

158
    e.logger.Info(ctx, "Recorded sent notification", map[string]interface{}{
159
        "user_id":           userID,
160
        "notification_type": notificationType,
161
        "status":            status,
162
    })
163

164
    return nil
165
}
166

167
// IsEnabled returns whether email functionality is enabled (always true for test service)
168
3x
func (e *TestEmailService) IsEnabled() bool {
169
3x
    return true
170
3x
}
171

172
// getMapKeys returns the keys of a map as a slice of strings
173
1x
func getMapKeys(data map[string]interface{}) []string {
174
1x
    keys := make([]string, 0, len(data))
175
1x
    for k := range data {
176
1x
        keys = append(keys, k)
177
1x
    }
178
1x
    return keys
179
}
180


			
quizapp internal services worker_service.go
66.7%
Statements
26/39
1
//go:build integration
2
// +build integration
3

4
package services
5

6
import (
7
    "context"
8
    "database/sql"
9
    "os"
10
    "testing"
11

12
    "quizapp/internal/config"
13
    "quizapp/internal/database"
14
    "quizapp/internal/observability"
15

16
    "github.com/stretchr/testify/require"
17
)
18

19
// SharedTestDBSetup provides a clean, isolated database for each integration test
20
// Uses the optimized CleanupTestDatabase function for consistent cleanup
21
159x
func SharedTestDBSetup(t *testing.T) *sql.DB {
22
159x
    observabilityLogger := observability.NewLogger(&config.OpenTelemetryConfig{EnableLogging: false})
23
159x
    dbManager := database.NewManager(observabilityLogger)
24
159x

25
159x
    // Require TEST_DATABASE_URL environment variable to be set
26
159x
    databaseURL := os.Getenv("TEST_DATABASE_URL")
27
159x
    if databaseURL == "" {
28
        t.Fatal("TEST_DATABASE_URL environment variable must be set for integration tests")
29
    }
30

31
159x
    db, err := dbManager.InitDB(databaseURL)
32
159x
    require.NoError(t, err)
33
159x

34
159x
    // Use the optimized cleanup function
35
159x
    CleanupTestDatabase(db, t)
36
159x

37
159x
    return db
38
}
39

40
// cleanupDatabase performs the core database cleanup operations
41
// This is the shared implementation used by both CleanupTestDatabase and SharedTestSuite.Cleanup
42
178x
func cleanupDatabase(db *sql.DB, logger *observability.Logger) {
43
178x
    ctx := context.Background()
44
178x
    tx, err := db.BeginTx(ctx, nil)
45
178x
    if err != nil {
46
        if logger != nil {
47
            logger.Error(ctx, "Failed to begin cleanup transaction", err)
48
        }
49
        return
50
    }
51
178x
    defer func() {
52
178x
        if err != nil {
53
            tx.Rollback()
54
        }
55
    }()
56

57
    // Fast cleanup with batched operations
58
178x
    cleanupQueries := []string{
59
178x
        "TRUNCATE TABLE user_responses CASCADE",
60
178x
        "TRUNCATE TABLE performance_metrics CASCADE",
61
178x
        "TRUNCATE TABLE user_question_metadata CASCADE",
62
178x
        "TRUNCATE TABLE question_priority_scores CASCADE",
63
178x
        "TRUNCATE TABLE user_learning_preferences CASCADE",
64
178x
        "TRUNCATE TABLE user_questions CASCADE",
65
178x
        "TRUNCATE TABLE questions CASCADE",
66
178x
        "TRUNCATE TABLE worker_status CASCADE",
67
178x
        "TRUNCATE TABLE worker_settings CASCADE",
68
178x
        "TRUNCATE TABLE user_api_keys CASCADE",
69
178x
        "TRUNCATE TABLE user_roles CASCADE",
70
178x
        "TRUNCATE TABLE question_reports CASCADE",
71
178x
        "TRUNCATE TABLE notification_errors CASCADE",
72
178x
        "TRUNCATE TABLE upcoming_notifications CASCADE",
73
178x
        "TRUNCATE TABLE sent_notifications CASCADE",
74
178x
        "TRUNCATE TABLE daily_question_assignments CASCADE",
75
178x
        "TRUNCATE TABLE users CASCADE",
76
178x
    }
77
178x

78
178x
    for _, query := range cleanupQueries {
79
3026x
        _, err := tx.ExecContext(ctx, query)
80
3026x
        if err != nil {
81
            if logger != nil {
82
                logger.Warn(ctx, "Could not execute cleanup query", map[string]interface{}{
83
                    "query": query,
84
                })
85
            }
86
        }
87
    }
88

89
    // Reset sequences
90
178x
    sequenceQueries := []string{
91
178x
        "ALTER SEQUENCE users_id_seq RESTART WITH 1",
92
178x
        "ALTER SEQUENCE questions_id_seq RESTART WITH 1",
93
178x
        "ALTER SEQUENCE user_responses_id_seq RESTART WITH 1",
94
178x
        "ALTER SEQUENCE performance_metrics_id_seq RESTART WITH 1",
95
178x
    }
96
178x

97
178x
    for _, query := range sequenceQueries {
98
712x
        _, err := tx.ExecContext(ctx, query)
99
712x
        if err != nil {
100
            if logger != nil {
101
                logger.Warn(ctx, "Could not reset sequence", map[string]interface{}{
102
                    "query": query,
103
                })
104
            }
105
        }
106
    }
107

108
    // Re-insert default worker settings
109
178x
    _, err = tx.ExecContext(ctx, `
110
178x
        INSERT INTO worker_settings (setting_key, setting_value, created_at, updated_at)
111
178x
        VALUES ('global_pause', 'false', NOW(), NOW())
112
178x
        ON CONFLICT (setting_key) DO NOTHING;
113
178x
    `)
114
178x
    if err != nil {
115
        if logger != nil {
116
            logger.Error(ctx, "Failed to insert worker settings", err)
117
        }
118
    }
119

120
178x
    err = tx.Commit()
121
178x
    if err != nil {
122
        if logger != nil {
123
            logger.Error(ctx, "Failed to commit cleanup transaction", err)
124
        }
125
    }
126
}
127

128
// CleanupTestDatabase cleans up the database for integration tests
129
// This function can be used by any integration test that needs to clean up the database
130
// Optimized to use batched transactions for better performance
131
178x
func CleanupTestDatabase(db *sql.DB, t *testing.T) {
132
178x
    cleanupDatabase(db, nil)
133
178x
}
134


			
quizapp internal services worker_service.go
63.4%
Statements
472/744
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "fmt"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/config"
12
    "quizapp/internal/models"
13
    "quizapp/internal/observability"
14
    contextutils "quizapp/internal/utils"
15

16
    "github.com/lib/pq"
17

18
    "go.opentelemetry.io/otel/attribute"
19
    "go.opentelemetry.io/otel/codes"
20
    "go.opentelemetry.io/otel/trace"
21
    "golang.org/x/crypto/bcrypt"
22
)
23

24
// UserServiceInterface defines the interface for user-related operations.
25
// This allows for easier mocking in tests.
26
type UserServiceInterface interface {
27
    CreateUser(ctx context.Context, username, language, level string) (*models.User, error)
28
    CreateUserWithPassword(ctx context.Context, username, password, language, level string) (*models.User, error)
29
    CreateUserWithEmailAndTimezone(ctx context.Context, username, email, timezone, language, level string) (*models.User, error)
30
    GetUserByID(ctx context.Context, id int) (*models.User, error)
31
    GetUserByUsername(ctx context.Context, username string) (*models.User, error)
32
    GetUserByEmail(ctx context.Context, email string) (*models.User, error)
33
    AuthenticateUser(ctx context.Context, username, password string) (*models.User, error)
34
    UpdateUserSettings(ctx context.Context, userID int, settings *models.UserSettings) error
35
    UpdateUserProfile(ctx context.Context, userID int, username, email, timezone string) error
36
    UpdateUserPassword(ctx context.Context, userID int, newPassword string) error
37
    UpdateLastActive(ctx context.Context, userID int) error
38
    GetAllUsers(ctx context.Context) ([]models.User, error)
39
    GetUsersPaginated(ctx context.Context, page, pageSize int, search, language, level, aiProvider, aiModel, aiEnabled, active string) ([]models.User, int, error)
40
    DeleteUser(ctx context.Context, userID int) error
41
    DeleteAllUsers(ctx context.Context) error
42
    EnsureAdminUserExists(ctx context.Context, adminUsername, adminPassword string) error
43
    ResetDatabase(ctx context.Context) error
44
    ClearUserData(ctx context.Context) error
45
    ClearUserDataForUser(ctx context.Context, userID int) error
46
    GetUserAPIKey(ctx context.Context, userID int, provider string) (string, error)
47
    SetUserAPIKey(ctx context.Context, userID int, provider, apiKey string) error
48
    HasUserAPIKey(ctx context.Context, userID int, provider string) (bool, error)
49
    // Role management methods
50
    GetUserRoles(ctx context.Context, userID int) ([]models.Role, error)
51
    GetAllRoles(ctx context.Context) ([]models.Role, error)
52
    AssignRole(ctx context.Context, userID, roleID int) error
53
    AssignRoleByName(ctx context.Context, userID int, roleName string) error
54
    RemoveRole(ctx context.Context, userID, roleID int) error
55
    HasRole(ctx context.Context, userID int, roleName string) (bool, error)
56
    IsAdmin(ctx context.Context, userID int) (bool, error)
57
    GetDB() *sql.DB
58
}
59

60
// UserService provides methods for user management.
61
type UserService struct {
62
    db     *sql.DB
63
    cfg    *config.Config
64
    logger *observability.Logger
65
}
66

67
// Shared query constants to eliminate duplication
68
const (
69
    // userSelectFields contains all user fields for SELECT queries
70
    userSelectFields = `id, username, email, timezone, password_hash, last_active, preferred_language, current_level, ai_provider, ai_model, ai_enabled, ai_api_key, created_at, updated_at`
71

72
    // userSelectFieldsNoPassword contains user fields excluding password_hash for GetAllUsers
73
    userSelectFieldsNoPassword = `id, username, email, timezone, last_active, preferred_language, current_level, ai_provider, ai_model, ai_enabled, ai_api_key, created_at, updated_at`
74
)
75

76
// scanUserFromRow scans a database row into a models.User struct
77
223x
func (s *UserService) scanUserFromRow(row *sql.Row) (result0 *models.User, err error) {
78
223x
    user := &models.User{}
79
223x
    err = row.Scan(
80
223x
        &user.ID, &user.Username, &user.Email, &user.Timezone, &user.PasswordHash, &user.LastActive,
81
223x
        &user.PreferredLanguage, &user.CurrentLevel, &user.AIProvider,
82
223x
        &user.AIModel, &user.AIEnabled, &user.AIAPIKey, &user.CreatedAt, &user.UpdatedAt,
83
223x
    )
84
223x
    if err != nil {
85
19x
        return nil, err
86
19x
    }
87
204x
    return user, nil
88
}
89

90
// scanUserFromRowsNoPassword scans a database rows into a models.User struct (without password_hash)
91
16x
func (s *UserService) scanUserFromRowsNoPassword(rows *sql.Rows) (result0 *models.User, err error) {
92
16x
    user := &models.User{}
93
16x
    err = rows.Scan(
94
16x
        &user.ID, &user.Username, &user.Email, &user.Timezone, &user.LastActive,
95
16x
        &user.PreferredLanguage, &user.CurrentLevel, &user.AIProvider,
96
16x
        &user.AIModel, &user.AIEnabled, &user.AIAPIKey, &user.CreatedAt, &user.UpdatedAt,
97
16x
    )
98
16x
    if err != nil {
99
        return nil, err
100
    }
101
16x
    return user, nil
102
}
103

104
// getUserByQuery is a shared method for getting a user by any query
105
223x
func (s *UserService) getUserByQuery(ctx context.Context, query string, args ...interface{}) (result0 *models.User, err error) {
106
223x
    row := s.db.QueryRowContext(ctx, query, args...)
107
223x
    var user *models.User
108
223x
    user, err = s.scanUserFromRow(row)
109
223x
    if err != nil {
110
19x
        if errors.Is(err, sql.ErrNoRows) {
111
19x
            return nil, nil // User not found is not an error here
112
19x
        }
113
        return nil, err
114
    }
115

116
    // Try to apply default settings, but don't fail if there's an issue
117
204x
    s.applyDefaultSettings(ctx, user)
118
204x
    return user, nil
119
}
120

121
// NewUserServiceWithLogger creates a new UserService instance with logger
122
118x
func NewUserServiceWithLogger(db *sql.DB, cfg *config.Config, logger *observability.Logger) *UserService {
123
118x
    return &UserService{
124
118x
        db:     db,
125
118x
        cfg:    cfg,
126
118x
        logger: logger,
127
118x
    }
128
118x
}
129

130
// CreateUser creates a new user with the specified username, language, and level
131
42x
func (s *UserService) CreateUser(ctx context.Context, username, language, level string) (result0 *models.User, err error) {
132
42x
    ctx, span := observability.TraceUserFunction(ctx, "create_user", attribute.String("user.username", username))
133
42x
    defer observability.FinishSpan(span, &err)
134
42x

135
42x
    // Validate username is not empty
136
42x
    if username == "" || len(strings.TrimSpace(username)) == 0 {
137
1x
        return nil, contextutils.WrapError(contextutils.ErrInvalidInput, "username cannot be empty")
138
1x
    }
139

140
    // default timezone to UTC for new users
141
41x
    query := `INSERT INTO users (username, preferred_language, current_level, last_active, created_at, updated_at, timezone) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`
142
41x
    now := time.Now()
143
41x
    var id int
144
41x
    err = s.db.QueryRowContext(ctx, query, username, language, level, now, now, now, "UTC").Scan(&id)
145
41x
    if err != nil {
146
1x
        return nil, err
147
1x
    }
148
40x
    var user *models.User
149
40x
    user, err = s.GetUserByID(ctx, id)
150
40x
    if err != nil {
151
        return nil, err
152
    }
153
40x
    if user == nil {
154
        return nil, contextutils.WrapError(contextutils.ErrDatabaseQuery, "user was created but could not be retrieved from database")
155
    }
156
40x
    return user, nil
157
}
158

159
// CreateUserWithEmailAndTimezone creates a new user with email and timezone
160
18x
func (s *UserService) CreateUserWithEmailAndTimezone(ctx context.Context, username, email, timezone, language, level string) (result0 *models.User, err error) {
161
18x
    ctx, span := observability.TraceUserFunction(ctx, "create_user_with_email", attribute.String("user.username", username))
162
18x
    defer observability.FinishSpan(span, &err)
163
18x

164
18x
    // Validate username is not empty
165
18x
    if username == "" || len(strings.TrimSpace(username)) == 0 {
166
        return nil, contextutils.WrapError(contextutils.ErrInvalidInput, "username cannot be empty")
167
    }
168

169
18x
    query := `INSERT INTO users (username, email, timezone, preferred_language, current_level, ai_enabled, last_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`
170
18x
    now := time.Now()
171
18x
    var id int
172
18x
    err = s.db.QueryRowContext(ctx, query, username, email, timezone, language, level, false, now, now, now).Scan(&id)
173
18x
    if err != nil {
174
        if isDuplicateKeyError(err) {
175
            return nil, contextutils.ErrRecordExists
176
        }
177
        return nil, err
178
    }
179
18x
    if err != nil {
180
        return nil, err
181
    }
182
18x
    var user *models.User
183
18x
    user, err = s.GetUserByID(ctx, id)
184
18x
    if err != nil {
185
        return nil, err
186
    }
187
18x
    if user == nil {
188
        return nil, contextutils.WrapError(contextutils.ErrDatabaseQuery, "user was created but could not be retrieved from database")
189
    }
190
18x
    return user, nil
191
}
192

193
// CreateUserWithPassword creates a new user with password authentication
194
79x
func (s *UserService) CreateUserWithPassword(ctx context.Context, username, password, language, level string) (result0 *models.User, err error) {
195
79x
    ctx, span := observability.TraceUserFunction(ctx, "create_user_with_password", attribute.String("user.username", username))
196
79x
    defer observability.FinishSpan(span, &err)
197
79x

198
79x
    // Validate username is not empty
199
79x
    if username == "" || len(strings.TrimSpace(username)) == 0 {
200
        return nil, contextutils.WrapError(contextutils.ErrInvalidInput, "username cannot be empty")
201
    }
202

203
    // Hash the password using bcrypt
204
79x
    var hashedPassword []byte
205
79x
    hashedPassword, err = bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
206
79x
    if err != nil {
207
        return nil, err
208
    }
209

210
    // default timezone to UTC for new users created with password
211
79x
    query := `INSERT INTO users (username, password_hash, preferred_language, current_level, ai_enabled, last_active, created_at, updated_at, timezone) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`
212
79x
    now := time.Now()
213
79x
    var id int
214
79x
    err = s.db.QueryRowContext(ctx, query, username, string(hashedPassword), language, level, false, now, now, now, "UTC").Scan(&id)
215
79x
    if err != nil {
216
        if isDuplicateKeyError(err) {
217
            return nil, contextutils.ErrRecordExists
218
        }
219
        return nil, err
220
    }
221
79x
    if err != nil {
222
        return nil, err
223
    }
224
79x
    user, err := s.GetUserByID(ctx, id)
225
79x
    if err != nil {
226
        return nil, err
227
    }
228
79x
    if user == nil {
229
        return nil, contextutils.WrapError(contextutils.ErrDatabaseQuery, "user was created but could not be retrieved from database")
230
    }
231
79x
    return user, nil
232
}
233

234
// AuthenticateUser verifies user credentials and returns the user if valid
235
9x
func (s *UserService) AuthenticateUser(ctx context.Context, username, password string) (result0 *models.User, err error) {
236
9x
    ctx, span := observability.TraceUserFunction(ctx, "authenticate_user", attribute.String("user.username", username))
237
9x
    defer observability.FinishSpan(span, &err)
238
9x
    // Get user by username
239
9x
    var user *models.User
240
9x
    user, err = s.GetUserByUsername(ctx, username)
241
9x
    if err != nil {
242
        return nil, err
243
    }
244
9x
    if user == nil {
245
1x
        return nil, errors.New("user not found")
246
1x
    }
247

248
    // Check if password hash exists
249
8x
    if !user.PasswordHash.Valid {
250
1x
        return nil, errors.New("user has no password set")
251
1x
    }
252

253
    // Compare provided password with stored hash
254
7x
    err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash.String), []byte(password))
255
7x
    if err != nil {
256
3x
        return nil, errors.New("invalid password")
257
3x
    }
258

259
4x
    return user, nil
260
}
261

262
// GetUserByID retrieves a user by their ID
263
204x
func (s *UserService) GetUserByID(ctx context.Context, id int) (result0 *models.User, err error) {
264
204x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_by_id", attribute.Int("user.id", id))
265
204x
    defer observability.FinishSpan(span, &err)
266
204x
    query := fmt.Sprintf("SELECT %s FROM users WHERE id = $1", userSelectFields)
267
204x
    var user *models.User
268
204x
    user, err = s.getUserByQuery(ctx, query, id)
269
204x
    if err != nil {
270
        s.logger.Error(ctx, "Database error retrieving user", err, map[string]interface{}{"user_id": id})
271
        return nil, err
272
    }
273
204x
    if user == nil {
274
14x
        s.logger.Debug(ctx, "User not found in database", map[string]interface{}{"user_id": id})
275
14x
        return nil, nil
276
14x
    }
277

278
    // Load user roles
279
190x
    roles, err := s.GetUserRoles(ctx, id)
280
190x
    if err != nil {
281
        s.logger.Warn(ctx, "Failed to load user roles", map[string]interface{}{"user_id": id, "error": err.Error()})
282
        // Don't fail the entire request if roles can't be loaded
283
        user.Roles = []models.Role{}
284
    } else {
285
190x
        user.Roles = roles
286
190x
    }
287

288
190x
    return user, nil
289
}
290

291
// GetUserByUsername retrieves a user by their username
292
15x
func (s *UserService) GetUserByUsername(ctx context.Context, username string) (result0 *models.User, err error) {
293
15x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_by_username", attribute.String("user.username", username))
294
15x
    defer observability.FinishSpan(span, &err)
295
15x
    query := fmt.Sprintf("SELECT %s FROM users WHERE username = $1", userSelectFields)
296
15x
    return s.getUserByQuery(ctx, query, username)
297
15x
}
298

299
// UpdateUserSettings updates user settings including AI configuration
300
10x
func (s *UserService) UpdateUserSettings(ctx context.Context, userID int, settings *models.UserSettings) (err error) {
301
10x
    ctx, span := observability.TraceUserFunction(ctx, "update_user_settings", attribute.Int("user.id", userID))
302
10x
    defer observability.FinishSpan(span, &err)
303
10x

304
10x
    // Check if user exists before updating settings
305
10x
    user, err := s.GetUserByID(ctx, userID)
306
10x
    if err != nil {
307
        return contextutils.WrapError(err, "failed to check if user exists")
308
    }
309
10x
    if user == nil {
310
1x
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
311
1x
    }
312

313
    // Start a transaction to update both user settings and API key
314
9x
    var tx *sql.Tx
315
9x
    tx, err = s.db.Begin()
316
9x
    if err != nil {
317
        return contextutils.WrapError(err, "failed to begin transaction for user settings update")
318
    }
319
9x
    defer func() {
320
9x
        if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
321
            s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
322
        }
323
    }()
324

325
    // Handle AI enabled logic
326
9x
    aiProvider := settings.AIProvider
327
9x
    aiModel := settings.AIModel
328
9x

329
9x
    // If AI is disabled, clear the provider and model
330
9x
    if !settings.AIEnabled {
331
2x
        aiProvider = ""
332
2x
        aiModel = ""
333
2x
    }
334

335
    // Update user settings (excluding API key which is now stored separately)
336
9x
    query := `UPDATE users SET preferred_language = $1, current_level = $2, ai_provider = $3, ai_model = $4, ai_enabled = $5, updated_at = $6 WHERE id = $7`
337
9x
    var result sql.Result
338
9x
    result, err = tx.ExecContext(ctx, query, settings.Language, settings.Level, aiProvider, aiModel, settings.AIEnabled, time.Now(), userID)
339
9x
    if err != nil {
340
        return contextutils.WrapError(err, "failed to update user settings in transaction")
341
    }
342

343
    // Check if the user was actually updated
344
9x
    rowsAffected, err := result.RowsAffected()
345
9x
    if err != nil {
346
        return contextutils.WrapError(err, "failed to get rows affected")
347
    }
348

349
9x
    if rowsAffected == 0 {
350
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "user with ID %d not found", userID)
351
    }
352

353
    // If an API key is provided and AI is enabled, save it for the specific provider
354
9x
    if settings.AIAPIKey != "" && settings.AIProvider != "" && settings.AIEnabled {
355
1x
        err = s.setUserAPIKeyTx(ctx, tx, userID, settings.AIProvider, settings.AIAPIKey)
356
1x
        if err != nil {
357
            return contextutils.WrapError(err, "failed to set user API key in transaction")
358
        }
359
    }
360

361
9x
    return tx.Commit()
362
}
363

364
// GetUserAPIKey retrieves the API key for a specific provider for a user
365
3x
func (s *UserService) GetUserAPIKey(ctx context.Context, userID int, provider string) (result0 string, err error) {
366
3x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_api_key", attribute.Int("user.id", userID), attribute.String("user.provider", provider))
367
3x
    defer observability.FinishSpan(span, &err)
368
3x

369
3x
    // Check if user exists before getting API key
370
3x
    user, err := s.GetUserByID(ctx, userID)
371
3x
    if err != nil {
372
        return "", contextutils.WrapError(err, "failed to check if user exists")
373
    }
374
3x
    if user == nil {
375
2x
        return "", contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
376
2x
    }
377

378
1x
    query := `SELECT api_key FROM user_api_keys WHERE user_id = $1 AND provider = $2`
379
1x
    var apiKey string
380
1x
    err = s.db.QueryRowContext(ctx, query, userID, provider).Scan(&apiKey)
381
1x
    if err != nil {
382
1x
        if errors.Is(err, sql.ErrNoRows) {
383
1x
            return "", contextutils.WrapError(contextutils.ErrRecordNotFound, "API key for provider not found")
384
1x
        }
385
        return "", contextutils.WrapError(err, "failed to get user API key")
386
    }
387
    return apiKey, nil
388
}
389

390
// SetUserAPIKey sets the API key for a specific provider for a user
391
3x
func (s *UserService) SetUserAPIKey(ctx context.Context, userID int, provider, apiKey string) (err error) {
392
3x
    ctx, span := observability.TraceUserFunction(ctx, "set_user_api_key", attribute.Int("user.id", userID), attribute.String("user.provider", provider))
393
3x
    defer observability.FinishSpan(span, &err)
394
3x

395
3x
    // Check if user exists before setting API key
396
3x
    user, err := s.GetUserByID(ctx, userID)
397
3x
    if err != nil {
398
        return contextutils.WrapError(err, "failed to check if user exists")
399
    }
400
3x
    if user == nil {
401
1x
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
402
1x
    }
403

404
2x
    var tx *sql.Tx
405
2x
    tx, err = s.db.Begin()
406
2x
    if err != nil {
407
        return contextutils.WrapError(err, "failed to begin transaction for API key update")
408
    }
409
2x
    defer func() {
410
2x
        if err != nil {
411
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
412
                s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
413
            }
414
        }
415
    }()
416

417
2x
    err = s.setUserAPIKeyTx(ctx, tx, userID, provider, apiKey)
418
2x
    if err != nil {
419
        return contextutils.WrapError(err, "failed to set user API key in transaction")
420
    }
421

422
2x
    commitErr := tx.Commit()
423
2x
    if commitErr != nil {
424
        return contextutils.WrapError(commitErr, "failed to commit API key transaction")
425
    }
426

427
    // Clear the error so defer doesn't try to rollback
428
2x
    err = nil
429
2x
    return nil
430
}
431

432
// setUserAPIKeyTx sets the API key for a specific provider within a transaction
433
3x
func (s *UserService) setUserAPIKeyTx(ctx context.Context, tx *sql.Tx, userID int, provider, apiKey string) error {
434
3x
    query := `INSERT INTO user_api_keys (user_id, provider, api_key, updated_at)
435
3x
              VALUES ($1, $2, $3, $4)
436
3x
              ON CONFLICT (user_id, provider)
437
3x
              DO UPDATE SET api_key = $3, updated_at = $4`
438
3x
    _, err := tx.ExecContext(ctx, query, userID, provider, apiKey, time.Now())
439
3x
    return contextutils.WrapError(err, "failed to execute API key transaction")
440
3x
}
441

442
// HasUserAPIKey checks if a user has an API key for a specific provider
443
1x
func (s *UserService) HasUserAPIKey(ctx context.Context, userID int, provider string) (result0 bool, err error) {
444
1x
    ctx, span := observability.TraceUserFunction(ctx, "has_user_api_key", attribute.Int("user.id", userID), attribute.String("user.provider", provider))
445
1x
    defer observability.FinishSpan(span, &err)
446
1x
    var apiKey string
447
1x
    apiKey, err = s.GetUserAPIKey(ctx, userID, provider)
448
1x
    if err != nil {
449
1x
        // If the error is "not found" and it's specifically about the API key not existing (not the user),
450
1x
        // then it means no API key exists, which is not an error
451
1x
        if errors.Is(err, contextutils.ErrRecordNotFound) {
452
1x
            // Check if the error message indicates it's about the API key, not the user
453
1x
            if strings.Contains(err.Error(), "API key for provider not found") {
454
                return false, nil
455
            }
456
            // If it's about the user not found, return the error
457
1x
            return false, err
458
        }
459
        return false, contextutils.WrapError(err, "failed to check if user has API key")
460
    }
461
    return apiKey != "", nil
462
}
463

464
// UpdateLastActive updates the user's last activity timestamp
465
1x
func (s *UserService) UpdateLastActive(ctx context.Context, userID int) (err error) {
466
1x
    ctx, span := observability.TraceUserFunction(ctx, "update_last_active", attribute.Int("user.id", userID))
467
1x
    defer observability.FinishSpan(span, &err)
468
1x
    query := `UPDATE users SET last_active = $1 WHERE id = $2`
469
1x
    var result sql.Result
470
1x
    result, err = s.db.ExecContext(ctx, query, time.Now(), userID)
471
1x
    if err != nil {
472
        return contextutils.WrapError(err, "failed to update user last active timestamp")
473
    }
474

475
    // Check if the user was actually updated
476
1x
    rowsAffected, err := result.RowsAffected()
477
1x
    if err != nil {
478
        return contextutils.WrapError(err, "failed to get rows affected")
479
    }
480

481
1x
    if rowsAffected == 0 {
482
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "user with ID %d not found", userID)
483
    }
484

485
1x
    return nil
486
}
487

488
// GetAllUsers retrieves all users from the database
489
4x
func (s *UserService) GetAllUsers(ctx context.Context) (result0 []models.User, err error) {
490
4x
    ctx, span := observability.TraceUserFunction(ctx, "get_all_users")
491
4x
    defer observability.FinishSpan(span, &err)
492
4x
    query := fmt.Sprintf("SELECT %s FROM users", userSelectFieldsNoPassword)
493
4x
    var rows *sql.Rows
494
4x
    rows, err = s.db.QueryContext(ctx, query)
495
4x
    if err != nil {
496
        return nil, contextutils.WrapError(err, "failed to query all users")
497
    }
498
4x
    defer func() {
499
4x
        if err = rows.Close(); err != nil {
500
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": err.Error()})
501
        }
502
    }()
503

504
4x
    var users []models.User
505
4x
    for rows.Next() {
506
16x
        user, err := s.scanUserFromRowsNoPassword(rows)
507
16x
        if err != nil {
508
            return nil, contextutils.WrapError(err, "failed to scan user from rows")
509
        }
510

511
        // Load user roles
512
16x
        roles, err := s.GetUserRoles(ctx, user.ID)
513
16x
        if err != nil {
514
            s.logger.Warn(ctx, "Failed to load user roles", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
515
            // Don't fail the entire request if roles can't be loaded
516
            user.Roles = []models.Role{}
517
        } else {
518
16x
            user.Roles = roles
519
16x
        }
520

521
16x
        users = append(users, *user)
522
    }
523

524
4x
    return users, nil
525
}
526

527
// GetUsersPaginated retrieves paginated users with filtering and search
528
func (s *UserService) GetUsersPaginated(ctx context.Context, page, pageSize int, search, language, level, aiProvider, aiModel, aiEnabled, active string) (result0 []models.User, result1 int, err error) {
529
    ctx, span := observability.TraceUserFunction(ctx, "get_users_paginated")
530
    defer observability.FinishSpan(span, &err)
531

532
    // Build WHERE clause and args
533
    var conditions []string
534
    var args []interface{}
535
    argIndex := 1
536

537
    // Search filter
538
    if search != "" {
539
        conditions = append(conditions, fmt.Sprintf("(username ILIKE $%d OR email ILIKE $%d)", argIndex, argIndex))
540
        args = append(args, "%"+search+"%")
541
        argIndex++
542
    }
543

544
    // Language filter
545
    if language != "" {
546
        conditions = append(conditions, fmt.Sprintf("preferred_language = $%d", argIndex))
547
        args = append(args, language)
548
        argIndex++
549
    }
550

551
    // Level filter
552
    if level != "" {
553
        conditions = append(conditions, fmt.Sprintf("current_level = $%d", argIndex))
554
        args = append(args, level)
555
        argIndex++
556
    }
557

558
    // AI Provider filter
559
    if aiProvider != "" {
560
        conditions = append(conditions, fmt.Sprintf("ai_provider = $%d", argIndex))
561
        args = append(args, aiProvider)
562
        argIndex++
563
    }
564

565
    // AI Model filter
566
    if aiModel != "" {
567
        conditions = append(conditions, fmt.Sprintf("ai_model = $%d", argIndex))
568
        args = append(args, aiModel)
569
        argIndex++
570
    }
571

572
    // AI Enabled filter
573
    if aiEnabled != "" {
574
        enabled := aiEnabled == "true"
575
        conditions = append(conditions, fmt.Sprintf("ai_enabled = $%d", argIndex))
576
        args = append(args, enabled)
577
        argIndex++
578
    }
579

580
    // Active filter (based on last_active within 7 days)
581
    if active != "" {
582
        activeThreshold := time.Now().AddDate(0, 0, -7)
583
        switch active {
584
        case "true":
585
            conditions = append(conditions, fmt.Sprintf("last_active >= $%d", argIndex))
586
            args = append(args, activeThreshold)
587
        case "false":
588
            conditions = append(conditions, fmt.Sprintf("(last_active < $%d OR last_active IS NULL)", argIndex))
589
            args = append(args, activeThreshold)
590
        }
591
        argIndex++
592
    }
593

594
    // Build WHERE clause
595
    whereClause := ""
596
    if len(conditions) > 0 {
597
        whereClause = "WHERE " + strings.Join(conditions, " AND ")
598
    }
599

600
    // Get total count
601
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM users %s", whereClause)
602
    var total int
603
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total)
604
    if err != nil {
605
        return nil, 0, contextutils.WrapError(err, "failed to count users")
606
    }
607

608
    // Get paginated results
609
    offset := (page - 1) * pageSize
610
    query := fmt.Sprintf("SELECT %s FROM users %s ORDER BY username LIMIT $%d OFFSET $%d",
611
        userSelectFieldsNoPassword, whereClause, argIndex, argIndex+1)
612
    args = append(args, pageSize, offset)
613

614
    rows, err := s.db.QueryContext(ctx, query, args...)
615
    if err != nil {
616
        return nil, 0, contextutils.WrapError(err, "failed to query paginated users")
617
    }
618
    defer func() {
619
        if closeErr := rows.Close(); closeErr != nil {
620
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
621
        }
622
    }()
623

624
    var users []models.User
625
    for rows.Next() {
626
        user, err := s.scanUserFromRowsNoPassword(rows)
627
        if err != nil {
628
            return nil, 0, contextutils.WrapError(err, "failed to scan user from rows")
629
        }
630

631
        // Load user roles
632
        roles, err := s.GetUserRoles(ctx, user.ID)
633
        if err != nil {
634
            s.logger.Warn(ctx, "Failed to load user roles", map[string]interface{}{"user_id": user.ID, "error": err.Error()})
635
            // Don't fail the entire request if roles can't be loaded
636
            user.Roles = []models.Role{}
637
        } else {
638
            user.Roles = roles
639
        }
640

641
        users = append(users, *user)
642
    }
643

644
    return users, total, nil
645
}
646

647
// GetUserByEmail retrieves a user by their email address
648
4x
func (s *UserService) GetUserByEmail(ctx context.Context, email string) (result0 *models.User, err error) {
649
4x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_by_email", attribute.String("user.email", email))
650
4x
    defer observability.FinishSpan(span, &err)
651
4x
    query := fmt.Sprintf("SELECT %s FROM users WHERE email = $1", userSelectFields)
652
4x
    return s.getUserByQuery(ctx, query, email)
653
4x
}
654

655
// UpdateUserProfile updates user profile information (username, email, timezone)
656
1x
func (s *UserService) UpdateUserProfile(ctx context.Context, userID int, username, email, timezone string) (err error) {
657
1x
    ctx, span := observability.TraceUserFunction(ctx, "update_user_profile", attribute.Int("user.id", userID))
658
1x
    defer observability.FinishSpan(span, &err)
659
1x
    query := `UPDATE users SET username = $1, email = $2, timezone = $3, updated_at = $4 WHERE id = $5`
660
1x
    var result sql.Result
661
1x
    result, err = s.db.ExecContext(ctx, query, username, email, timezone, time.Now(), userID)
662
1x
    if err != nil {
663
        return contextutils.WrapError(err, "failed to update user profile")
664
    }
665

666
    // Check if the user was actually updated
667
1x
    rowsAffected, err := result.RowsAffected()
668
1x
    if err != nil {
669
        return contextutils.WrapError(err, "failed to get rows affected")
670
    }
671

672
1x
    if rowsAffected == 0 {
673
        return contextutils.WrapErrorf(contextutils.ErrRecordNotFound, "user with ID %d not found", userID)
674
    }
675

676
1x
    return nil
677
}
678

679
// UpdateUserPassword updates a user's password
680
5x
func (s *UserService) UpdateUserPassword(ctx context.Context, userID int, newPassword string) (err error) {
681
5x
    ctx, span := observability.TraceUserFunction(ctx, "update_user_password", attribute.Int("user.id", userID))
682
5x
    defer observability.FinishSpan(span, &err)
683
5x

684
5x
    // Validate password is not empty
685
5x
    if newPassword == "" {
686
1x
        return contextutils.ErrorWithContextf("password cannot be empty")
687
1x
    }
688

689
    // Check if user exists first
690
4x
    user, err := s.GetUserByID(ctx, userID)
691
4x
    if err != nil {
692
        return contextutils.WrapError(err, "failed to check if user exists")
693
    }
694
4x
    if user == nil {
695
1x
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
696
1x
    }
697

698
    // Hash the new password using bcrypt
699
3x
    var hashedPassword []byte
700
3x
    hashedPassword, err = bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
701
3x
    if err != nil {
702
        return contextutils.WrapError(err, "failed to hash password")
703
    }
704

705
3x
    query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3`
706
3x
    result, err := s.db.ExecContext(ctx, query, string(hashedPassword), time.Now(), userID)
707
3x
    if err != nil {
708
        return contextutils.WrapError(err, "failed to update user password")
709
    }
710

711
    // Check if any rows were affected
712
3x
    rowsAffected, err := result.RowsAffected()
713
3x
    if err != nil {
714
        return contextutils.WrapError(err, "failed to get rows affected")
715
    }
716

717
3x
    if rowsAffected == 0 {
718
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
719
    }
720

721
3x
    s.logger.Info(ctx, "Password updated successfully", map[string]interface{}{"user_id": userID, "username": user.Username})
722
3x
    return nil
723
}
724

725
// DeleteUser removes a user and their associated data
726
3x
func (s *UserService) DeleteUser(ctx context.Context, userID int) (err error) {
727
3x
    ctx, span := observability.TraceUserFunction(ctx, "delete_user", attribute.Int("user.id", userID))
728
3x
    defer observability.FinishSpan(span, &err)
729
3x

730
3x
    // Check if user exists before deleting
731
3x
    user, err := s.GetUserByID(ctx, userID)
732
3x
    if err != nil {
733
        return contextutils.WrapError(err, "failed to check if user exists")
734
    }
735
3x
    if user == nil {
736
1x
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
737
1x
    }
738

739
    // Best-effort cleanup of dependent rows for tables that may not have ON DELETE CASCADE in some environments
740
    // This keeps tests deterministic and avoids orphaned data
741
    // TODO: This is a hack to make the tests deterministic. We should use ON DELETE CASCADE instead.
742
2x
    cleanupQueries := []string{
743
2x
        `DELETE FROM question_reports WHERE reported_by_user_id = $1`,
744
2x
        `DELETE FROM user_api_keys WHERE user_id = $1`,
745
2x
        `DELETE FROM user_roles WHERE user_id = $1`,
746
2x
        `DELETE FROM user_learning_preferences WHERE user_id = $1`,
747
2x
        `DELETE FROM question_priority_scores WHERE user_id = $1`,
748
2x
        `DELETE FROM user_question_metadata WHERE user_id = $1`,
749
2x
        `DELETE FROM user_responses WHERE user_id = $1`,
750
2x
        `DELETE FROM user_questions WHERE user_id = $1`,
751
2x
    }
752
2x
    for _, q := range cleanupQueries {
753
16x
        if _, err := s.db.ExecContext(ctx, q, userID); err != nil {
754
            s.logger.Warn(ctx, "Non-fatal cleanup failure during user delete", map[string]interface{}{"error": err.Error(), "query": q, "user_id": userID})
755
        }
756
    }
757

758
    // Delete the user
759
2x
    query := `DELETE FROM users WHERE id = $1`
760
2x
    result, err := s.db.ExecContext(ctx, query, userID)
761
2x
    if err != nil {
762
        return contextutils.WrapError(err, "failed to delete user")
763
    }
764

765
2x
    rowsAffected, err := result.RowsAffected()
766
2x
    if err != nil {
767
        return contextutils.WrapError(err, "failed to get rows affected")
768
    }
769

770
2x
    if rowsAffected == 0 {
771
        return contextutils.WrapError(contextutils.ErrRecordNotFound, "user not found")
772
    }
773

774
2x
    s.logger.Info(ctx, "User %d deleted successfully", map[string]interface{}{"user_id": userID})
775
2x
    return nil
776
}
777

778
// DeleteAllUsers removes all users from the database
779
2x
func (s *UserService) DeleteAllUsers(ctx context.Context) (err error) {
780
2x
    ctx, span := observability.TraceUserFunction(ctx, "delete_all_users")
781
2x
    defer observability.FinishSpan(span, &err)
782
2x
    var tx *sql.Tx
783
2x
    tx, err = s.db.Begin()
784
2x
    if err != nil {
785
        return contextutils.WrapError(err, "failed to begin transaction for delete all users")
786
    }
787
2x
    defer func() {
788
2x
        if err != nil {
789
            if rollbackErr := tx.Rollback(); rollbackErr != nil {
790
                s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
791
            }
792
        }
793
    }()
794

795
    // Whitelist of valid table names to prevent SQL injection
796
2x
    validTables := map[string]bool{
797
2x
        "user_responses":      true,
798
2x
        "performance_metrics": true,
799
2x
        "users":               true,
800
2x
    }
801
2x

802
2x
    // Delete all data in the correct order (to respect foreign key constraints)
803
2x
    tables := []string{
804
2x
        "user_responses",
805
2x
        "performance_metrics",
806
2x
        "users",
807
2x
    }
808
2x

809
2x
    for _, table := range tables {
810
6x
        // Validate table name against whitelist
811
6x
        if !validTables[table] {
812
            return contextutils.ErrorWithContextf("invalid table name: %s", table)
813
        }
814

815
        // Use parameterized query with validated table name
816
6x
        query := fmt.Sprintf("DELETE FROM %s", table)
817
6x
        if _, err := tx.ExecContext(ctx, query); err != nil {
818
            return contextutils.WrapErrorf(err, "failed to delete from table %s", table)
819
        }
820
        // Reset sequence for PostgreSQL
821
6x
        sequenceQuery := fmt.Sprintf("ALTER SEQUENCE %s_id_seq RESTART WITH 1", table)
822
6x
        if _, err := tx.ExecContext(ctx, sequenceQuery); err != nil {
823
            // This might fail if the table doesn't have a sequence, so we log but don't fail
824
            s.logger.Warn(ctx, "Note: Could not reset sequence for %s (this is normal for some tables)", map[string]interface{}{"table": table})
825
        }
826
    }
827

828
2x
    return contextutils.WrapError(tx.Commit(), "failed to commit delete all users transaction")
829
}
830

831
// EnsureAdminUserExists creates the admin user if it doesn't exist
832
5x
func (s *UserService) EnsureAdminUserExists(ctx context.Context, adminUsername, adminPassword string) (err error) {
833
5x
    ctx, span := observability.TraceUserFunction(ctx, "ensure_admin_user_exists", attribute.String("admin.username", adminUsername))
834
5x
    defer observability.FinishSpan(span, &err)
835
5x

836
5x
    // Validate input parameters
837
5x
    if adminUsername == "" {
838
1x
        return contextutils.ErrorWithContextf("admin username cannot be empty")
839
1x
    }
840

841
4x
    if adminPassword == "" {
842
1x
        return contextutils.ErrorWithContextf("admin password cannot be empty")
843
1x
    }
844
    // Check if admin user already exists
845
3x
    var existingUser *models.User
846
3x
    existingUser, err = s.GetUserByUsername(ctx, adminUsername)
847
3x
    if err != nil {
848
        return contextutils.WrapError(err, "failed to check if admin user exists")
849
    }
850

851
3x
    if existingUser != nil {
852
1x
        // User exists, check if password needs to be updated
853
1x
        if existingUser.PasswordHash.Valid {
854
1x
            // User has a password, test if it matches current admin password
855
1x
            err = bcrypt.CompareHashAndPassword([]byte(existingUser.PasswordHash.String), []byte(adminPassword))
856
1x
            if err == nil {
857
                // Password matches, ensure AI settings are configured
858
                err = s.ensureAdminAISettings(ctx, existingUser.ID)
859
                if err != nil {
860
                    s.logger.Warn(ctx, "Warning: Failed to set AI settings for existing admin user", map[string]interface{}{"error": err.Error()})
861
                }
862

863
                // Ensure admin user has email and timezone if not set
864
                if !existingUser.Email.Valid || !existingUser.Timezone.Valid {
865
                    err = s.ensureAdminProfile(ctx, existingUser.ID)
866
                    if err != nil {
867
                        s.logger.Warn(ctx, "Warning: Failed to update admin profile", map[string]interface{}{"error": err.Error()})
868
                    }
869
                }
870

871
                // Ensure admin user has admin role
872
                isAdmin, err := s.IsAdmin(ctx, existingUser.ID)
873
                if err != nil {
874
                    s.logger.Warn(ctx, "Warning: Failed to check admin role for existing admin user", map[string]interface{}{"error": err.Error()})
875
                } else if !isAdmin {
876
                    err = s.AssignRoleByName(ctx, existingUser.ID, "admin")
877
                    if err != nil {
878
                        s.logger.Warn(ctx, "Warning: Failed to assign admin role to existing admin user", map[string]interface{}{"error": err.Error()})
879
                    }
880
                }
881

882
                s.logger.Info(ctx, "Admin user already exists with correct password", map[string]interface{}{"username": adminUsername})
883
                return nil
884
            }
885
        }
886

887
        // Update password
888
1x
        hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
889
1x
        if err != nil {
890
            return contextutils.WrapError(err, "failed to hash admin password")
891
        }
892

893
1x
        query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE username = $3`
894
1x
        _, err = s.db.ExecContext(ctx, query, string(hashedPassword), time.Now(), adminUsername)
895
1x
        if err != nil {
896
            return contextutils.WrapError(err, "failed to update admin user password")
897
        }
898

899
        // Ensure AI settings are configured
900
1x
        err = s.ensureAdminAISettings(ctx, existingUser.ID)
901
1x
        if err != nil {
902
            s.logger.Warn(ctx, "Warning: Failed to set AI settings for existing admin user", map[string]interface{}{"error": err.Error()})
903
        }
904

905
        // Ensure admin user has email and timezone if not set
906
1x
        if !existingUser.Email.Valid || !existingUser.Timezone.Valid {
907
            err = s.ensureAdminProfile(ctx, existingUser.ID)
908
            if err != nil {
909
                s.logger.Warn(ctx, "Warning: Failed to update admin profile", map[string]interface{}{"error": err.Error()})
910
            }
911
        }
912

913
        // Ensure admin user has admin role
914
1x
        isAdmin, err := s.IsAdmin(ctx, existingUser.ID)
915
1x
        if err != nil {
916
            s.logger.Warn(ctx, "Warning: Failed to check admin role for existing admin user", map[string]interface{}{"error": err.Error()})
917
        } else if !isAdmin {
918
            err = s.AssignRoleByName(ctx, existingUser.ID, "admin")
919
            if err != nil {
920
                s.logger.Warn(ctx, "Warning: Failed to assign admin role to existing admin user", map[string]interface{}{"error": err.Error()})
921
            }
922
        }
923

924
1x
        s.logger.Info(ctx, "Updated password for admin user", map[string]interface{}{"username": adminUsername})
925
1x
        return nil
926
    }
927

928
    // Create new admin user with email and timezone
929
2x
    user, err := s.CreateUserWithEmailAndTimezone(ctx, adminUsername, "admin@example.com", "America/New_York", "italian", "A1")
930
2x
    if err != nil {
931
        return contextutils.WrapError(err, "failed to create admin user")
932
    }
933

934
    // Set password for the admin user
935
2x
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
936
2x
    if err != nil {
937
        return contextutils.WrapError(err, "failed to hash new admin password")
938
    }
939

940
2x
    query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3`
941
2x
    _, err = s.db.ExecContext(ctx, query, string(hashedPassword), time.Now(), user.ID)
942
2x
    if err != nil {
943
        return contextutils.WrapError(err, "failed to set password for new admin user")
944
    }
945

946
    // Set up AI settings for the admin user
947
2x
    err = s.ensureAdminAISettings(ctx, user.ID)
948
2x
    if err != nil {
949
        s.logger.Warn(ctx, "Warning: Failed to set AI settings for new admin user", map[string]interface{}{"error": err.Error()})
950
    }
951

952
    // Assign admin role to the admin user
953
2x
    err = s.AssignRoleByName(ctx, user.ID, "admin")
954
2x
    if err != nil {
955
        s.logger.Warn(ctx, "Warning: Failed to assign admin role to new admin user", map[string]interface{}{"error": err.Error()})
956
    }
957

958
2x
    s.logger.Info(ctx, "Created admin user", map[string]interface{}{"username": adminUsername})
959
2x
    return nil
960
}
961

962
// ensureAdminAISettings ensures the admin user has AI settings configured
963
// Only sets default values if the user doesn't already have AI settings configured
964
3x
func (s *UserService) ensureAdminAISettings(ctx context.Context, userID int) (err error) {
965
3x
    ctx, span := observability.TraceUserFunction(ctx, "ensure_admin_ai_settings", attribute.Int("user.id", userID))
966
3x
    defer observability.FinishSpan(span, &err)
967
3x
    var user *models.User
968
3x
    user, err = s.GetUserByID(ctx, userID)
969
3x
    if err != nil {
970
        return err
971
    }
972
3x
    if user == nil {
973
        return errors.New("admin user not found")
974
    }
975

976
    // If user already has AI provider configured, don't override their settings
977
3x
    if user.AIProvider.Valid && user.AIProvider.String != "" {
978
1x
        s.logger.Info(ctx, "User ID already has AI settings configured, preserving existing settings", map[string]interface{}{"user_id": userID, "provider": user.AIProvider.String})
979
1x
        return nil
980
1x
    }
981

982
    // Set default AI settings with a default API key
983
2x
    settings := &models.UserSettings{
984
2x
        AIProvider: "ollama",
985
2x
        AIModel:    "llama4:latest",
986
2x
        AIAPIKey:   "not_needed", // Default API key
987
2x
    }
988
2x

989
2x
    // Only update AI settings, preserve other user settings
990
2x
    query := `UPDATE users SET ai_provider = $1, ai_model = $2, ai_api_key = $3, updated_at = $4 WHERE id = $5`
991
2x
    _, err = s.db.ExecContext(ctx, query, settings.AIProvider, settings.AIModel, settings.AIAPIKey, time.Now(), userID)
992
2x
    if err != nil {
993
        return contextutils.WrapError(err, "failed to update user AI settings")
994
    }
995

996
    // Save the API key to the user_api_keys table
997
2x
    err = s.SetUserAPIKey(ctx, userID, settings.AIProvider, settings.AIAPIKey)
998
2x
    if err != nil {
999
        s.logger.Warn(ctx, "Warning: Failed to save API key for user %d", map[string]interface{}{"user_id": userID, "error": err.Error()})
1000
    }
1001

1002
2x
    s.logger.Info(ctx, "Set default AI settings for user", map[string]interface{}{"user_id": userID, "provider": settings.AIProvider, "model": settings.AIModel})
1003
2x
    return nil
1004
}
1005

1006
// ensureAdminProfile ensures the admin user has email and timezone set
1007
func (s *UserService) ensureAdminProfile(ctx context.Context, userID int) (err error) {
1008
    ctx, span := observability.TraceUserFunction(ctx, "ensure_admin_profile", attribute.Int("user.id", userID))
1009
    defer observability.FinishSpan(span, &err)
1010
    query := `UPDATE users SET email = $1, timezone = $2, updated_at = $3 WHERE id = $4 AND (email IS NULL OR timezone IS NULL)`
1011
    _, err = s.db.ExecContext(ctx, query, "admin@example.com", "America/New_York", time.Now(), userID)
1012
    if err != nil {
1013
        return contextutils.WrapError(err, "failed to update admin profile")
1014
    }
1015

1016
    s.logger.Info(ctx, "Updated admin user profile with default email and timezone", map[string]interface{}{"user_id": userID})
1017
    return nil
1018
}
1019

1020
// ResetDatabase completely resets the database to an empty state
1021
func (s *UserService) ResetDatabase(ctx context.Context) (err error) {
1022
    ctx, span := observability.TraceUserFunction(ctx, "reset_database")
1023
    defer observability.FinishSpan(span, &err)
1024
    var tx *sql.Tx
1025
    tx, err = s.db.Begin()
1026
    if err != nil {
1027
        return contextutils.WrapError(err, "failed to begin transaction for database reset")
1028
    }
1029
    defer func() {
1030
        if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
1031
            s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
1032
        }
1033
    }()
1034

1035
    // Whitelist of valid table names to prevent SQL injection
1036
    validTables := map[string]bool{
1037
        "user_responses":      true,
1038
        "performance_metrics": true,
1039
        "questions":           true,
1040
        "users":               true,
1041
    }
1042

1043
    // Delete all data in the correct order (to respect foreign key constraints)
1044
    tables := []string{
1045
        "user_responses",
1046
        "performance_metrics",
1047
        "questions",
1048
        "users",
1049
    }
1050

1051
    for _, table := range tables {
1052
        // Validate table name against whitelist
1053
        if !validTables[table] {
1054
            return contextutils.ErrorWithContextf("invalid table name: %s", table)
1055
        }
1056

1057
        // Use parameterized query with validated table name
1058
        query := fmt.Sprintf("DELETE FROM %s", table)
1059
        if _, err := tx.ExecContext(ctx, query); err != nil {
1060
            return contextutils.WrapErrorf(err, "failed to delete from table %s during reset", table)
1061
        }
1062
        s.logger.Info(ctx, "Cleared table: %s", map[string]interface{}{"table": table})
1063

1064
        // Reset sequence for PostgreSQL
1065
        sequenceQuery := fmt.Sprintf("ALTER SEQUENCE %s_id_seq RESTART WITH 1", table)
1066
        if _, err := tx.ExecContext(ctx, sequenceQuery); err != nil {
1067
            // This might fail if the table doesn't have a sequence, so we log but don't fail
1068
            s.logger.Warn(ctx, "Note: Could not reset sequence for %s (this is normal for some tables)", map[string]interface{}{"table": table})
1069
        }
1070
    }
1071

1072
    err = tx.Commit()
1073
    if err != nil {
1074
        return contextutils.WrapError(err, "failed to commit database reset transaction")
1075
    }
1076

1077
    s.logger.Info(ctx, "Database reset completed successfully")
1078
    return nil
1079
}
1080

1081
// ClearUserData removes all user activity data but keeps the users themselves
1082
1x
func (s *UserService) ClearUserData(ctx context.Context) (err error) {
1083
1x
    ctx, span := observability.TraceUserFunction(ctx, "clear_user_data")
1084
1x
    defer observability.FinishSpan(span, &err)
1085
1x
    var tx *sql.Tx
1086
1x
    tx, err = s.db.Begin()
1087
1x
    if err != nil {
1088
        return contextutils.WrapError(err, "failed to begin transaction for clear user data")
1089
    }
1090
1x
    defer func() {
1091
1x
        if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
1092
            s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
1093
        }
1094
    }()
1095

1096
    // Whitelist of valid table names to prevent SQL injection
1097
1x
    validTables := map[string]bool{
1098
1x
        "user_responses":      true,
1099
1x
        "performance_metrics": true,
1100
1x
        "questions":           true,
1101
1x
    }
1102
1x

1103
1x
    // Delete user data but keep users (order matters due to foreign key constraints)
1104
1x
    tables := []string{
1105
1x
        "user_responses",
1106
1x
        "performance_metrics",
1107
1x
        "questions",
1108
1x
    }
1109
1x

1110
1x
    for _, table := range tables {
1111
3x
        // Validate table name against whitelist
1112
3x
        if !validTables[table] {
1113
            return contextutils.ErrorWithContextf("invalid table name: %s", table)
1114
        }
1115

1116
        // Use parameterized query with validated table name
1117
3x
        query := fmt.Sprintf("DELETE FROM %s", table)
1118
3x
        if _, err := tx.ExecContext(ctx, query); err != nil {
1119
            return contextutils.WrapErrorf(err, "failed to delete from table %s during clear user data", table)
1120
        }
1121
3x
        s.logger.Info(ctx, "Cleared table: %s", map[string]interface{}{"table": table})
1122
3x

1123
3x
        // Reset sequence for PostgreSQL
1124
3x
        sequenceQuery := fmt.Sprintf("ALTER SEQUENCE %s_id_seq RESTART WITH 1", table)
1125
3x
        if _, err := tx.ExecContext(ctx, sequenceQuery); err != nil {
1126
            // This might fail if the table doesn't have a sequence, so we log but don't fail
1127
            s.logger.Warn(ctx, "Note: Could not reset sequence for %s (this is normal for some tables)", map[string]interface{}{"table": table})
1128
        }
1129
    }
1130

1131
1x
    err = tx.Commit()
1132
1x
    if err != nil {
1133
        return contextutils.WrapError(err, "failed to commit clear user data transaction")
1134
    }
1135

1136
1x
    s.logger.Info(ctx, "User data cleared successfully (users preserved)")
1137
1x
    return nil
1138
}
1139

1140
// ClearUserDataForUser removes all user activity data for a specific user but keeps the user record
1141
2x
func (s *UserService) ClearUserDataForUser(ctx context.Context, userID int) (err error) {
1142
2x
    ctx, span := observability.TraceUserFunction(ctx, "clear_user_data_for_user", attribute.Int("user.id", userID))
1143
2x
    defer observability.FinishSpan(span, &err)
1144
2x
    var tx *sql.Tx
1145
2x
    tx, err = s.db.Begin()
1146
2x
    if err != nil {
1147
        s.logger.Warn(ctx, "Failed to begin transaction", map[string]interface{}{"error": err.Error()})
1148
        return contextutils.WrapError(err, "failed to begin transaction for clear user data for specific user")
1149
    }
1150
2x
    defer func() {
1151
2x
        if rollbackErr := tx.Rollback(); rollbackErr != nil && rollbackErr != sql.ErrTxDone {
1152
            s.logger.Warn(ctx, "Warning: failed to rollback transaction", map[string]interface{}{"error": rollbackErr.Error()})
1153
        }
1154
    }()
1155

1156
    // Delete user_responses for this user's questions (via user_questions)
1157
2x
    query := `DELETE FROM user_responses WHERE question_id IN (SELECT question_id FROM user_questions WHERE user_id = $1)`
1158
2x
    result, err := tx.ExecContext(ctx, query, userID)
1159
2x
    if err != nil {
1160
        s.logger.Warn(ctx, "Failed to delete user_responses", map[string]interface{}{"error": err.Error()})
1161
        return contextutils.WrapError(err, "failed to delete user responses for specific user")
1162
    }
1163
2x
    rows, _ := result.RowsAffected()
1164
2x
    s.logger.Info(ctx, "Deleted %d user_responses for user %d", map[string]interface{}{"count": rows, "user_id": userID})
1165
2x

1166
2x
    // Delete performance_metrics for this user (performance_metrics has user_id, not question_id)
1167
2x
    query = `DELETE FROM performance_metrics WHERE user_id = $1`
1168
2x
    result, err = tx.ExecContext(ctx, query, userID)
1169
2x
    if err != nil {
1170
        s.logger.Warn(ctx, "Failed to delete performance_metrics", map[string]interface{}{"error": err.Error()})
1171
        return contextutils.WrapError(err, "failed to delete performance metrics for specific user")
1172
    }
1173
2x
    rows, _ = result.RowsAffected()
1174
2x
    s.logger.Info(ctx, "Deleted %d performance_metrics for user %d", map[string]interface{}{"count": rows, "user_id": userID})
1175
2x

1176
2x
    // Delete user_questions for this user
1177
2x
    query = `DELETE FROM user_questions WHERE user_id = $1`
1178
2x
    result, err = tx.ExecContext(ctx, query, userID)
1179
2x
    if err != nil {
1180
        s.logger.Warn(ctx, "Failed to delete user_questions", map[string]interface{}{"error": err.Error()})
1181
        return contextutils.WrapError(err, "failed to delete user questions for specific user")
1182
    }
1183
2x
    rows, _ = result.RowsAffected()
1184
2x
    s.logger.Info(ctx, "Deleted %d user_questions for user %d", map[string]interface{}{"count": rows, "user_id": userID})
1185
2x

1186
2x
    // Optionally, delete orphaned questions (not assigned to any user)
1187
2x
    query = `DELETE FROM questions WHERE id NOT IN (SELECT question_id FROM user_questions)`
1188
2x
    result, err = tx.ExecContext(ctx, query)
1189
2x
    if err != nil {
1190
        s.logger.Warn(ctx, "Failed to delete orphaned questions", map[string]interface{}{"error": err.Error()})
1191
        return contextutils.WrapError(err, "failed to delete orphaned questions")
1192
    }
1193
2x
    rows, _ = result.RowsAffected()
1194
2x
    s.logger.Info(ctx, "Deleted %d orphaned questions", map[string]interface{}{"count": rows})
1195
2x

1196
2x
    if err := tx.Commit(); err != nil {
1197
        s.logger.Warn(ctx, "Failed to commit transaction", map[string]interface{}{"error": err.Error()})
1198
        return contextutils.WrapError(err, "failed to commit clear user data for specific user transaction")
1199
    }
1200
2x
    s.logger.Info(ctx, "User data cleared successfully for user %d (users preserved)", map[string]interface{}{"user_id": userID})
1201
2x
    return nil
1202
}
1203

1204
204x
func (s *UserService) applyDefaultSettings(ctx context.Context, user *models.User) {
1205
204x
    if user == nil || s.cfg == nil {
1206
        return
1207
    }
1208
204x
    _, span := observability.TraceUserFunction(ctx, "apply_default_settings", attribute.Int("user.id", user.ID))
1209
204x
    defer span.End()
1210
204x
    if user.AIProvider.String == "" && len(s.cfg.Providers) > 0 {
1211
170x
        // Use the first available provider as default
1212
170x
        provider := s.cfg.Providers[0]
1213
170x
        user.AIProvider.String = provider.Code
1214
170x
        // Use first model in the list as default
1215
170x
        if len(provider.Models) > 0 {
1216
170x
            user.AIModel.String = provider.Models[0].Code
1217
170x
        }
1218
    }
1219
204x
    if user.CurrentLevel.String == "" {
1220
1x
        // Set default level based on user's preferred language, or use first available language
1221
1x
        language := user.PreferredLanguage.String
1222
1x
        if language == "" {
1223
1x
            languages := s.cfg.GetLanguages()
1224
1x
            if len(languages) > 0 {
1225
1x
                language = languages[0]
1226
1x
            }
1227
        }
1228
1x
        if language != "" {
1229
1x
            levels := s.cfg.GetLevelsForLanguage(language)
1230
1x
            if len(levels) > 0 {
1231
1x
                user.CurrentLevel.String = levels[0]
1232
1x
            }
1233
        }
1234
    }
1235
204x
    if user.PreferredLanguage.String == "" {
1236
1x
        user.PreferredLanguage.String = "english"
1237
1x
    }
1238
}
1239

1240
// GetUserRoles retrieves all roles for a user
1241
222x
func (s *UserService) GetUserRoles(ctx context.Context, userID int) (result0 []models.Role, err error) {
1242
222x
    ctx, span := observability.TraceUserFunction(ctx, "get_user_roles", attribute.Int("user.id", userID))
1243
222x
    defer func() {
1244
222x
        if err != nil {
1245
            span.RecordError(err, trace.WithStackTrace(true))
1246
            span.SetStatus(codes.Error, err.Error())
1247
        }
1248
222x
        span.End()
1249
    }()
1250

1251
222x
    query := `
1252
222x
        SELECT r.id, r.name, r.description, r.created_at, r.updated_at
1253
222x
        FROM roles r
1254
222x
        JOIN user_roles ur ON r.id = ur.role_id
1255
222x
        WHERE ur.user_id = $1
1256
222x
        ORDER BY r.name
1257
222x
    `
1258
222x
    rows, err := s.db.QueryContext(ctx, query, userID)
1259
222x
    if err != nil {
1260
        return nil, contextutils.WrapError(err, "failed to get user roles")
1261
    }
1262
222x
    defer func() {
1263
222x
        if closeErr := rows.Close(); closeErr != nil {
1264
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
1265
        }
1266
    }()
1267

1268
222x
    var roles []models.Role
1269
222x
    for rows.Next() {
1270
31x
        var role models.Role
1271
31x
        err := rows.Scan(&role.ID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt)
1272
31x
        if err != nil {
1273
            return nil, contextutils.WrapError(err, "failed to scan user role")
1274
        }
1275
31x
        roles = append(roles, role)
1276
    }
1277

1278
222x
    if err = rows.Err(); err != nil {
1279
        return nil, contextutils.WrapError(err, "error iterating user roles")
1280
    }
1281

1282
222x
    return roles, nil
1283
}
1284

1285
// AssignRole assigns a role to a user
1286
12x
func (s *UserService) AssignRole(ctx context.Context, userID, roleID int) (err error) {
1287
12x
    ctx, span := observability.TraceUserFunction(ctx, "assign_role", attribute.Int("user.id", userID), attribute.Int("role.id", roleID))
1288
12x
    defer func() {
1289
12x
        if err != nil {
1290
4x
            span.RecordError(err, trace.WithStackTrace(true))
1291
4x
            span.SetStatus(codes.Error, err.Error())
1292
4x
        }
1293
12x
        span.End()
1294
    }()
1295

1296
    // Check if user exists
1297
12x
    user, err := s.GetUserByID(ctx, userID)
1298
12x
    if err != nil {
1299
        return contextutils.WrapError(err, "failed to get user for role assignment")
1300
    }
1301
12x
    if user == nil {
1302
2x
        return contextutils.ErrorWithContextf("user with ID %d not found", userID)
1303
2x
    }
1304

1305
    // Check if role exists
1306
10x
    var roleName string
1307
10x
    err = s.db.QueryRowContext(ctx, "SELECT name FROM roles WHERE id = $1", roleID).Scan(&roleName)
1308
10x
    if err != nil {
1309
2x
        if errors.Is(err, sql.ErrNoRows) {
1310
2x
            return contextutils.ErrorWithContextf("role with ID %d not found", roleID)
1311
2x
        }
1312
        return contextutils.WrapError(err, "failed to check role existence")
1313
    }
1314

1315
    // Assign role (using ON CONFLICT DO NOTHING to handle duplicate assignments gracefully)
1316
8x
    query := `INSERT INTO user_roles (user_id, role_id, created_at) VALUES ($1, $2, $3) ON CONFLICT (user_id, role_id) DO NOTHING`
1317
8x
    _, err = s.db.ExecContext(ctx, query, userID, roleID, time.Now())
1318
8x
    if err != nil {
1319
        return contextutils.WrapError(err, "failed to assign role to user")
1320
    }
1321

1322
8x
    s.logger.Info(ctx, "Role assigned successfully", map[string]interface{}{
1323
8x
        "user_id":   userID,
1324
8x
        "role_id":   roleID,
1325
8x
        "role_name": roleName,
1326
8x
    })
1327
8x

1328
8x
    return nil
1329
}
1330

1331
// AssignRoleByName assigns a role to a user by role name
1332
9x
func (s *UserService) AssignRoleByName(ctx context.Context, userID int, roleName string) (err error) {
1333
9x
    ctx, span := observability.TraceUserFunction(ctx, "assign_role_by_name", attribute.Int("user.id", userID), attribute.String("role.name", roleName))
1334
9x
    defer func() {
1335
9x
        if err != nil {
1336
3x
            span.RecordError(err, trace.WithStackTrace(true))
1337
3x
            span.SetStatus(codes.Error, err.Error())
1338
3x
        }
1339
9x
        span.End()
1340
    }()
1341

1342
    // Check if user exists
1343
9x
    user, err := s.GetUserByID(ctx, userID)
1344
9x
    if err != nil {
1345
        return contextutils.WrapError(err, "failed to get user for role assignment")
1346
    }
1347
9x
    if user == nil {
1348
1x
        return contextutils.ErrorWithContextf("user with ID %d not found", userID)
1349
1x
    }
1350

1351
    // Get role ID by name
1352
8x
    var roleID int
1353
8x
    err = s.db.QueryRowContext(ctx, "SELECT id FROM roles WHERE name = $1", roleName).Scan(&roleID)
1354
8x
    if err != nil {
1355
2x
        if errors.Is(err, sql.ErrNoRows) {
1356
2x
            return contextutils.ErrorWithContextf("role with name '%s' not found", roleName)
1357
2x
        }
1358
        return contextutils.WrapError(err, "failed to get role ID by name")
1359
    }
1360

1361
    // Assign role (using ON CONFLICT DO NOTHING to handle duplicate assignments gracefully)
1362
6x
    query := `INSERT INTO user_roles (user_id, role_id, created_at) VALUES ($1, $2, $3) ON CONFLICT (user_id, role_id) DO NOTHING`
1363
6x
    _, err = s.db.ExecContext(ctx, query, userID, roleID, time.Now())
1364
6x
    if err != nil {
1365
        return contextutils.WrapError(err, "failed to assign role to user")
1366
    }
1367

1368
6x
    s.logger.Info(ctx, "Role assigned successfully", map[string]interface{}{
1369
6x
        "user_id":   userID,
1370
6x
        "role_id":   roleID,
1371
6x
        "role_name": roleName,
1372
6x
    })
1373
6x

1374
6x
    return nil
1375
}
1376

1377
// RemoveRole removes a role from a user
1378
4x
func (s *UserService) RemoveRole(ctx context.Context, userID, roleID int) (err error) {
1379
4x
    ctx, span := observability.TraceUserFunction(ctx, "remove_role", attribute.Int("user.id", userID), attribute.Int("role.id", roleID))
1380
4x
    defer func() {
1381
4x
        if err != nil {
1382
3x
            span.RecordError(err, trace.WithStackTrace(true))
1383
3x
            span.SetStatus(codes.Error, err.Error())
1384
3x
        }
1385
4x
        span.End()
1386
    }()
1387

1388
    // Check if user exists
1389
4x
    user, err := s.GetUserByID(ctx, userID)
1390
4x
    if err != nil {
1391
        return contextutils.WrapError(err, "failed to get user for role removal")
1392
    }
1393
4x
    if user == nil {
1394
1x
        return contextutils.ErrorWithContextf("user with ID %d not found", userID)
1395
1x
    }
1396

1397
    // Check if role exists
1398
3x
    var roleName string
1399
3x
    err = s.db.QueryRowContext(ctx, "SELECT name FROM roles WHERE id = $1", roleID).Scan(&roleName)
1400
3x
    if err != nil {
1401
1x
        if errors.Is(err, sql.ErrNoRows) {
1402
1x
            return contextutils.ErrorWithContextf("role with ID %d not found", roleID)
1403
1x
        }
1404
        return contextutils.WrapError(err, "failed to check role existence")
1405
    }
1406

1407
    // Remove role
1408
2x
    query := `DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2`
1409
2x
    result, err := s.db.ExecContext(ctx, query, userID, roleID)
1410
2x
    if err != nil {
1411
        return contextutils.WrapError(err, "failed to remove role from user")
1412
    }
1413

1414
2x
    rowsAffected, err := result.RowsAffected()
1415
2x
    if err != nil {
1416
        return contextutils.WrapError(err, "failed to get rows affected")
1417
    }
1418

1419
2x
    if rowsAffected == 0 {
1420
1x
        return contextutils.ErrorWithContextf("user %d does not have role %d", userID, roleID)
1421
1x
    }
1422

1423
1x
    s.logger.Info(ctx, "Role removed successfully", map[string]interface{}{
1424
1x
        "user_id":   userID,
1425
1x
        "role_id":   roleID,
1426
1x
        "role_name": roleName,
1427
1x
    })
1428
1x

1429
1x
    return nil
1430
}
1431

1432
// HasRole checks if a user has a specific role by name
1433
19x
func (s *UserService) HasRole(ctx context.Context, userID int, roleName string) (result0 bool, err error) {
1434
19x
    ctx, span := observability.TraceUserFunction(ctx, "has_role", attribute.Int("user.id", userID), attribute.String("role.name", roleName))
1435
19x
    defer func() {
1436
19x
        if err != nil {
1437
            span.RecordError(err, trace.WithStackTrace(true))
1438
            span.SetStatus(codes.Error, err.Error())
1439
        }
1440
19x
        span.End()
1441
    }()
1442

1443
19x
    query := `
1444
19x
        SELECT COUNT(*) > 0
1445
19x
        FROM user_roles ur
1446
19x
        JOIN roles r ON ur.role_id = r.id
1447
19x
        WHERE ur.user_id = $1 AND r.name = $2
1448
19x
    `
1449
19x
    var hasRole bool
1450
19x
    err = s.db.QueryRowContext(ctx, query, userID, roleName).Scan(&hasRole)
1451
19x
    if err != nil {
1452
        return false, contextutils.WrapError(err, "failed to check if user has role")
1453
    }
1454

1455
19x
    return hasRole, nil
1456
}
1457

1458
// IsAdmin checks if a user has admin role
1459
8x
func (s *UserService) IsAdmin(ctx context.Context, userID int) (result0 bool, err error) {
1460
8x
    ctx, span := observability.TraceUserFunction(ctx, "is_admin", attribute.Int("user.id", userID))
1461
8x
    defer observability.FinishSpan(span, &err)
1462
8x

1463
8x
    return s.HasRole(ctx, userID, "admin")
1464
8x
}
1465

1466
// GetAllRoles returns all available roles in the system
1467
func (s *UserService) GetAllRoles(ctx context.Context) (result0 []models.Role, err error) {
1468
    ctx, span := observability.TraceUserFunction(ctx, "get_all_roles")
1469
    defer observability.FinishSpan(span, &err)
1470

1471
    query := `
1472
        SELECT id, name, description, created_at, updated_at
1473
        FROM roles
1474
        ORDER BY name
1475
    `
1476
    rows, err := s.db.QueryContext(ctx, query)
1477
    if err != nil {
1478
        return nil, contextutils.WrapError(err, "failed to get all roles")
1479
    }
1480
    defer func() {
1481
        if closeErr := rows.Close(); closeErr != nil {
1482
            s.logger.Warn(ctx, "Warning: failed to close rows", map[string]interface{}{"error": closeErr.Error()})
1483
        }
1484
    }()
1485

1486
    var roles []models.Role
1487
    for rows.Next() {
1488
        var role models.Role
1489
        err := rows.Scan(&role.ID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt)
1490
        if err != nil {
1491
            return nil, contextutils.WrapError(err, "failed to scan role")
1492
        }
1493
        roles = append(roles, role)
1494
    }
1495

1496
    if err = rows.Err(); err != nil {
1497
        return nil, contextutils.WrapError(err, "error iterating roles")
1498
    }
1499

1500
    return roles, nil
1501
}
1502

1503
// GetDB returns the database connection
1504
func (s *UserService) GetDB() *sql.DB {
1505
    return s.db
1506
}
1507

1508
// isDuplicateKeyError checks if the error is a duplicate key constraint violation
1509
func isDuplicateKeyError(err error) bool {
1510
    if err == nil {
1511
        return false
1512
    }
1513

1514
    // Check for PostgreSQL unique constraint violation error code
1515
    if pqErr, ok := err.(*pq.Error); ok {
1516
        // PostgreSQL error code 23505 is for unique constraint violations
1517
        if pqErr.Code == "23505" {
1518
            return true
1519
        }
1520
    }
1521

1522
    return false
1523
}
1524


			
quizapp internal services worker_service.go
83.2%
Statements
89/107
1
package services
2

3
import (
4
    "context"
5
    "math/rand"
6

7
    "go.opentelemetry.io/otel/attribute"
8

9
    "quizapp/internal/config"
10
    "quizapp/internal/observability"
11
)
12

13
// VarietyService handles the selection of variety elements for question generation
14
type VarietyService struct {
15
    cfg    *config.Config
16
    logger *observability.Logger
17
}
18

19
// VarietyElements holds the randomly selected variety elements for a question generation request
20
type VarietyElements struct {
21
    TopicCategory      string
22
    GrammarFocus       string
23
    VocabularyDomain   string
24
    Scenario           string
25
    StyleModifier      string
26
    DifficultyModifier string
27
    TimeContext        string
28
}
29

30
// NewVarietyServiceWithLogger creates a new VarietyService with logger
31
70x
func NewVarietyServiceWithLogger(cfg *config.Config, logger *observability.Logger) *VarietyService {
32
70x
    return &VarietyService{
33
70x
        cfg:    cfg,
34
70x
        logger: logger,
35
70x
    }
36
70x
}
37

38
// SelectVarietyElements randomly selects variety elements for question generation
39
// If highPriorityTopics or userWeakAreas are provided, bias topic selection toward those topics first, then gapAnalysis.
40
1277x
func (vs *VarietyService) SelectVarietyElements(ctx context.Context, level string, highPriorityTopics, userWeakAreas []string, gapAnalysis map[string]int) *VarietyElements {
41
1277x
    _, span := observability.TraceVarietyFunction(ctx, "select_variety_elements",
42
1277x
        attribute.String("variety.level", level),
43
1277x
        attribute.Int("variety.high_priority_topics_count", len(highPriorityTopics)),
44
1277x
        attribute.Int("variety.user_weak_areas_count", len(userWeakAreas)),
45
1277x
        attribute.Int("variety.gap_analysis_count", len(gapAnalysis)),
46
1277x
    )
47
1277x
    defer span.End()
48
1277x

49
1277x
    // Get variety configuration from config
50
1277x
    if vs.cfg.Variety != nil {
51
1276x
        variety := vs.cfg.Variety
52
1276x
        elements := &VarietyElements{}
53
1276x

54
1276x
        // Helper function to get weighted selection from gap analysis
55
1276x
        getWeightedSelection := func(gapType string, availableOptions []string) string {
56
3403x
            if len(gapAnalysis) == 0 || len(availableOptions) == 0 {
57
621x
                return ""
58
621x
            }
59

60
2782x
            var weightedOptions []string
61
2782x
            for _, option := range availableOptions {
62
9011x
                gapKey := gapType + "_" + option
63
9011x
                if count, ok := gapAnalysis[gapKey]; ok && count > 0 {
64
1475x
                    // Intensify weighting by squaring the severity to reduce randomness sensitivity
65
1475x
                    weight := count * count
66
1475x
                    for range weight {
67
8622x
                        weightedOptions = append(weightedOptions, option)
68
8622x
                    }
69
                }
70
            }
71

72
2782x
            if len(weightedOptions) > 0 {
73
1034x
                return weightedOptions[rand.Intn(len(weightedOptions))]
74
1034x
            }
75
1748x
            return ""
76
        }
77

78
        // Define all possible variety elements with their selection functions
79
1276x
        type varietySelector struct {
80
1276x
            name     string
81
1276x
            selector func() string
82
1276x
        }
83
1276x

84
1276x
        var selectors []varietySelector
85
1276x

86
1276x
        // Topic category selector (biased by userWeakAreas, highPriorityTopics, then gapAnalysis if provided)
87
1276x
        if len(variety.TopicCategories) > 0 {
88
1276x
            selectors = append(selectors, varietySelector{
89
1276x
                name: "topic_category",
90
1276x
                selector: func() string {
91
824x
                    // 1. UserWeakAreas
92
824x
                    if len(userWeakAreas) > 0 {
93
                        var matching []string
94
                        for _, topic := range variety.TopicCategories {
95
                            for _, weak := range userWeakAreas {
96
                                if topic == weak {
97
                                    matching = append(matching, topic)
98
                                }
99
                            }
100
                        }
101
                        if len(matching) > 0 {
102
                            elements.TopicCategory = matching[rand.Intn(len(matching))]
103
                            return elements.TopicCategory
104
                        }
105
                    }
106
                    // 2. HighPriorityTopics
107
824x
                    if len(highPriorityTopics) > 0 {
108
                        var matching []string
109
                        for _, topic := range variety.TopicCategories {
110
                            for _, high := range highPriorityTopics {
111
                                if topic == high {
112
                                    matching = append(matching, topic)
113
                                }
114
                            }
115
                        }
116
                        if len(matching) > 0 {
117
                            elements.TopicCategory = matching[rand.Intn(len(matching))]
118
                            return elements.TopicCategory
119
                        }
120
                    }
121
                    // 3. GapAnalysis for topics
122
824x
                    if selected := getWeightedSelection("topic_category", variety.TopicCategories); selected != "" {
123
385x
                        elements.TopicCategory = selected
124
385x
                        return elements.TopicCategory
125
385x
                    }
126
                    // Fallback to random
127
439x
                    elements.TopicCategory = variety.TopicCategories[rand.Intn(len(variety.TopicCategories))]
128
439x
                    return elements.TopicCategory
129
                },
130
            })
131
        }
132

133
        // Grammar focus selector (now with gap analysis support)
134
1276x
        if grammarByLevel, exists := variety.GrammarFocusByLevel[level]; exists && len(grammarByLevel) > 0 {
135
1275x
            selectors = append(selectors, varietySelector{
136
1275x
                name: "grammar_focus",
137
1275x
                selector: func() string {
138
844x
                    // Check for grammar gaps first
139
844x
                    if selected := getWeightedSelection("grammar_focus", grammarByLevel); selected != "" {
140
99x
                        elements.GrammarFocus = selected
141
99x
                        return elements.GrammarFocus
142
99x
                    }
143
                    // Fallback to random
144
646x
                    elements.GrammarFocus = grammarByLevel[rand.Intn(len(grammarByLevel))]
145
646x
                    return elements.GrammarFocus
146
                },
147
            })
148
1x
        } else if len(variety.GrammarFocus) > 0 {
149
1x
            selectors = append(selectors, varietySelector{
150
1x
                name: "grammar_focus",
151
1x
                selector: func() string {
152
1x
                    // Check for grammar gaps first
153
1x
                    if selected := getWeightedSelection("grammar_focus", variety.GrammarFocus); selected != "" {
154
                        elements.GrammarFocus = selected
155
                        return elements.GrammarFocus
156
                    }
157
                    // Fallback to random
158
1x
                    elements.GrammarFocus = variety.GrammarFocus[rand.Intn(len(variety.GrammarFocus))]
159
1x
                    return elements.GrammarFocus
160
                },
161
            })
162
        }
163

164
        // Vocabulary domain selector (now with gap analysis support)
165
1276x
        if len(variety.VocabularyDomains) > 0 {
166
1273x
            selectors = append(selectors, varietySelector{
167
1273x
                name: "vocabulary_domain",
168
1273x
                selector: func() string {
169
858x
                    // Check for vocabulary gaps first
170
858x
                    if selected := getWeightedSelection("vocabulary_domain", variety.VocabularyDomains); selected != "" {
171
183x
                        elements.VocabularyDomain = selected
172
183x
                        return elements.VocabularyDomain
173
183x
                    }
174
                    // Fallback to random
175
675x
                    elements.VocabularyDomain = variety.VocabularyDomains[rand.Intn(len(variety.VocabularyDomains))]
176
675x
                    return elements.VocabularyDomain
177
                },
178
            })
179
        }
180

181
        // Scenario selector (now with gap analysis support)
182
1276x
        if len(variety.Scenarios) > 0 {
183
1273x
            selectors = append(selectors, varietySelector{
184
1273x
                name: "scenario",
185
1273x
                selector: func() string {
186
875x
                    // Check for scenario gaps first
187
875x
                    if selected := getWeightedSelection("scenario", variety.Scenarios); selected != "" {
188
268x
                        elements.Scenario = selected
189
268x
                        return elements.Scenario
190
268x
                    }
191
                    // Fallback to random
192
607x
                    elements.Scenario = variety.Scenarios[rand.Intn(len(variety.Scenarios))]
193
607x
                    return elements.Scenario
194
                },
195
            })
196
        }
197

198
        // Style modifier selector
199
1276x
        if len(variety.StyleModifiers) > 0 {
200
1273x
            selectors = append(selectors, varietySelector{
201
1273x
                name: "style_modifier",
202
1273x
                selector: func() string {
203
837x
                    elements.StyleModifier = variety.StyleModifiers[rand.Intn(len(variety.StyleModifiers))]
204
837x
                    return elements.StyleModifier
205
837x
                },
206
            })
207
        }
208

209
        // Difficulty modifier selector
210
1276x
        if len(variety.DifficultyModifiers) > 0 {
211
1273x
            selectors = append(selectors, varietySelector{
212
1273x
                name: "difficulty_modifier",
213
1273x
                selector: func() string {
214
834x
                    elements.DifficultyModifier = variety.DifficultyModifiers[rand.Intn(len(variety.DifficultyModifiers))]
215
834x
                    return elements.DifficultyModifier
216
834x
                },
217
            })
218
        }
219

220
        // Time context selector
221
1276x
        if len(variety.TimeContexts) > 0 {
222
1273x
            selectors = append(selectors, varietySelector{
223
1273x
                name: "time_context",
224
1273x
                selector: func() string {
225
789x
                    elements.TimeContext = variety.TimeContexts[rand.Intn(len(variety.TimeContexts))]
226
789x
                    return elements.TimeContext
227
789x
                },
228
            })
229
        }
230

231
        // Randomly select 2-3 variety elements (instead of all 7)
232
1276x
        numToSelect := 2
233
1276x
        if len(selectors) > 2 {
234
1273x
            // 70% chance of 2 elements, 30% chance of 3 elements
235
1273x
            if rand.Float64() < 0.3 {
236
759x
                numToSelect = 3
237
759x
            }
238
        }
239

240
        // Shuffle and select the first numToSelect elements
241
1276x
        rand.Shuffle(len(selectors), func(i, j int) {
242
7641x
            selectors[i], selectors[j] = selectors[j], selectors[i]
243
7641x
        })
244

245
        // Apply the selected variety elements
246
1276x
        for i := 0; i < numToSelect && i < len(selectors); i++ {
247
5863x
            selected := selectors[i].selector()
248
5863x
            span.SetAttributes(attribute.String("variety."+selectors[i].name, selected))
249
5863x
        }
250

251
1276x
        span.SetAttributes(
252
1276x
            attribute.String("variety.topic_category", elements.TopicCategory),
253
1276x
            attribute.String("variety.grammar_focus", elements.GrammarFocus),
254
1276x
            attribute.String("variety.vocabulary_domain", elements.VocabularyDomain),
255
1276x
            attribute.String("variety.scenario", elements.Scenario),
256
1276x
            attribute.String("variety.style_modifier", elements.StyleModifier),
257
1276x
            attribute.String("variety.difficulty_modifier", elements.DifficultyModifier),
258
1276x
            attribute.String("variety.time_context", elements.TimeContext),
259
1276x
            attribute.Int("variety.elements_selected", numToSelect),
260
1276x
        )
261
1276x

262
1276x
        span.SetAttributes(attribute.String("variety.result", "success"))
263
1276x
        return elements
264
    }
265

266
1x
    span.SetAttributes(attribute.String("variety.result", "no_config"))
267
1x
    return &VarietyElements{} // Return empty if no variety config
268
}
269

270
// SelectMultipleVarietyElements selects multiple sets of variety elements for batch generation
271
1x
func (vs *VarietyService) SelectMultipleVarietyElements(ctx context.Context, level string, count int) []*VarietyElements {
272
1x
    ctx, span := observability.TraceVarietyFunction(ctx, "select_multiple_variety_elements",
273
1x
        attribute.String("variety.level", level),
274
1x
        attribute.Int("variety.count", count),
275
1x
    )
276
1x
    defer span.End()
277
1x

278
1x
    elements := make([]*VarietyElements, count)
279
1x
    for i := 0; i < count; i++ {
280
3x
        elements[i] = vs.SelectVarietyElements(ctx, level, nil, nil, nil)
281
3x
    }
282

283
1x
    span.SetAttributes(attribute.String("variety.result", "success"), attribute.Int("variety.elements_count", len(elements)))
284
1x
    return elements
285
}
286


			
quizapp internal services worker_service.go
30.3%
Statements
195/644
1
package services
2

3
import (
4
    "context"
5
    "database/sql"
6
    "errors"
7
    "fmt"
8
    "strings"
9
    "time"
10

11
    "quizapp/internal/models"
12
    "quizapp/internal/observability"
13
    contextutils "quizapp/internal/utils"
14

15
    "go.opentelemetry.io/otel/attribute"
16
)
17

18
// ErrSettingNotFound is returned when a setting is not found in the database
19
var ErrSettingNotFound = errors.New("setting not found")
20

21
// WorkerServiceInterface defines the interface for worker management operations
22
type WorkerServiceInterface interface {
23
    // Settings management
24
    GetSetting(ctx context.Context, key string) (string, error)
25
    SetSetting(ctx context.Context, key, value string) error
26
    IsGlobalPaused(ctx context.Context) (bool, error)
27
    SetGlobalPause(ctx context.Context, paused bool) error
28
    IsUserPaused(ctx context.Context, userID int) (bool, error)
29
    SetUserPause(ctx context.Context, userID int, paused bool) error
30

31
    // Status management
32
    UpdateWorkerStatus(ctx context.Context, instance string, status *models.WorkerStatus) error
33
    GetWorkerStatus(ctx context.Context, instance string) (*models.WorkerStatus, error)
34
    GetAllWorkerStatuses(ctx context.Context) ([]models.WorkerStatus, error)
35
    UpdateHeartbeat(ctx context.Context, instance string) error
36
    IsWorkerHealthy(ctx context.Context, instance string) (bool, error)
37

38
    // Control operations
39
    PauseWorker(ctx context.Context, instance string) error
40
    ResumeWorker(ctx context.Context, instance string) error
41
    GetWorkerHealth(ctx context.Context) (map[string]interface{}, error)
42
    GetHighPriorityTopics(ctx context.Context, userID int, language, level, questionType string) ([]string, error)
43
    GetGapAnalysis(ctx context.Context, userID int, language, level, questionType string) (map[string]int, error)
44
    GetPriorityDistribution(ctx context.Context, userID int, language, level, questionType string) (map[string]int, error)
45

46
    // Notification management
47
    GetNotificationStats(ctx context.Context) (map[string]interface{}, error)
48
    GetNotificationErrors(ctx context.Context, page, pageSize int, errorType, notificationType, resolved string) ([]map[string]interface{}, map[string]interface{}, map[string]interface{}, error)
49
    GetUpcomingNotifications(ctx context.Context, page, pageSize int, notificationType, status, scheduledAfter, scheduledBefore string) ([]map[string]interface{}, map[string]interface{}, map[string]interface{}, error)
50
    GetSentNotifications(ctx context.Context, page, pageSize int, notificationType, status, sentAfter, sentBefore string) ([]map[string]interface{}, map[string]interface{}, map[string]interface{}, error)
51

52
    // Test methods for creating test data
53
    CreateTestSentNotification(ctx context.Context, userID int, notificationType, subject, templateName, status, errorMessage string) error
54
}
55

56
// WorkerService implements worker management operations
57
type WorkerService struct {
58
    db     *sql.DB
59
    logger *observability.Logger
60
}
61

62
// NewWorkerServiceWithLogger creates a new WorkerService instance with logger
63
14x
func NewWorkerServiceWithLogger(db *sql.DB, logger *observability.Logger) *WorkerService {
64
14x
    return &WorkerService{
65
14x
        db:     db,
66
14x
        logger: logger,
67
14x
    }
68
14x
}
69

70
// GetSetting retrieves a setting value by key
71
10x
func (s *WorkerService) GetSetting(ctx context.Context, key string) (result0 string, err error) {
72
10x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_setting", attribute.String("setting.key", key))
73
10x
    defer observability.FinishSpan(span, &err)
74
10x

75
10x
    // Validate key
76
10x
    if len(key) == 0 || len(strings.TrimSpace(key)) == 0 {
77
1x
        return "", contextutils.WrapErrorf(errors.New("invalid setting key"), "setting key cannot be empty")
78
1x
    }
79

80
9x
    var value string
81
9x
    err = s.db.QueryRowContext(ctx, `
82
9x
        SELECT setting_value FROM worker_settings WHERE setting_key = $1
83
9x
    `, key).Scan(&value)
84
9x
    if err != nil {
85
3x
        if err == sql.ErrNoRows {
86
2x
            s.logger.Debug(ctx, "Setting not found", map[string]interface{}{"setting_key": key})
87
2x
            return "", contextutils.WrapErrorf(ErrSettingNotFound, "%s", key)
88
2x
        }
89
1x
        s.logger.Error(ctx, "Failed to get setting", err, map[string]interface{}{"setting_key": key})
90
1x
        return "", contextutils.WrapErrorf(err, "failed to get setting %s", key)
91
    }
92

93
6x
    return value, nil
94
}
95

96
// SetSetting updates or creates a setting
97
10x
func (s *WorkerService) SetSetting(ctx context.Context, key, value string) (err error) {
98
10x
    ctx, span := observability.TraceWorkerFunction(ctx, "set_setting", attribute.String("setting.key", key))
99
10x
    defer observability.FinishSpan(span, &err)
100
10x

101
10x
    // Validate key
102
10x
    if len(key) == 0 || len(strings.TrimSpace(key)) == 0 {
103
1x
        return contextutils.WrapErrorf(errors.New("invalid setting key"), "setting key cannot be empty")
104
1x
    }
105

106
9x
    _, err = s.db.ExecContext(ctx, `
107
9x
        INSERT INTO worker_settings (setting_key, setting_value, updated_at)
108
9x
        VALUES ($1, $2, NOW())
109
9x
        ON CONFLICT (setting_key) DO UPDATE SET
110
9x
            setting_value = EXCLUDED.setting_value,
111
9x
            updated_at = EXCLUDED.updated_at
112
9x
    `, key, value)
113
9x
    if err != nil {
114
2x
        s.logger.Error(ctx, "Failed to set setting", err, map[string]interface{}{"setting_key": key, "setting_value": value})
115
2x
        return contextutils.WrapErrorf(err, "failed to set setting %s", key)
116
2x
    }
117

118
7x
    s.logger.Debug(ctx, "Setting updated", map[string]interface{}{"setting_key": key, "setting_value": value})
119
7x
    return nil
120
}
121

122
// IsGlobalPaused checks if the worker is globally paused
123
4x
func (s *WorkerService) IsGlobalPaused(ctx context.Context) (result0 bool, err error) {
124
4x
    ctx, span := observability.TraceWorkerFunction(ctx, "is_global_paused")
125
4x
    defer observability.FinishSpan(span, &err)
126
4x

127
4x
    var value string
128
4x
    value, err = s.GetSetting(ctx, "global_pause")
129
4x
    if err != nil {
130
        // If setting doesn't exist, default to false (not paused)
131
        if errors.Is(err, ErrSettingNotFound) {
132
            // Initialize the setting with default value
133
            if setErr := s.SetSetting(ctx, "global_pause", "false"); setErr != nil {
134
                s.logger.Error(ctx, "Failed to initialize global_pause setting", setErr, map[string]interface{}{})
135
                return false, contextutils.WrapError(setErr, "failed to initialize global_pause setting")
136
            }
137
            return false, nil
138
        }
139
        s.logger.Error(ctx, "Failed to check global pause status", err, map[string]interface{}{})
140
        return false, err
141
    }
142

143
4x
    paused := value == "true"
144
4x
    s.logger.Debug(ctx, "Global pause status checked", map[string]interface{}{"global_paused": paused})
145
4x
    return paused, nil
146
}
147

148
// SetGlobalPause sets the global pause state
149
3x
func (s *WorkerService) SetGlobalPause(ctx context.Context, paused bool) (err error) {
150
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "set_global_pause", attribute.Bool("paused", paused))
151
3x
    defer observability.FinishSpan(span, &err)
152
3x

153
3x
    value := "false"
154
3x
    if paused {
155
2x
        value = "true"
156
2x
    }
157

158
3x
    err = s.SetSetting(ctx, "global_pause", value)
159
3x
    if err != nil {
160
1x
        return err
161
1x
    }
162

163
2x
    s.logger.Info(ctx, "Global pause state updated", map[string]interface{}{"global_paused": paused})
164
2x
    return nil
165
}
166

167
// IsUserPaused checks if a specific user is paused
168
5x
func (s *WorkerService) IsUserPaused(ctx context.Context, userID int) (result0 bool, err error) {
169
5x
    ctx, span := observability.TraceWorkerFunction(ctx, "is_user_paused", observability.AttributeUserID(userID))
170
5x
    defer observability.FinishSpan(span, &err)
171
5x

172
5x
    key := fmt.Sprintf("user_pause_%d", userID)
173
5x
    var value string
174
5x
    err = s.db.QueryRowContext(ctx, `
175
5x
        SELECT setting_value FROM worker_settings WHERE setting_key = $1
176
5x
    `, key).Scan(&value)
177
5x
    if err != nil {
178
2x
        if err == sql.ErrNoRows {
179
2x
            // If setting doesn't exist, user is not paused (this is the default state)
180
2x
            s.logger.Debug(ctx, "User pause setting not found, defaulting to not paused", map[string]interface{}{"user_id": userID})
181
2x
            return false, nil
182
2x
        }
183
        s.logger.Error(ctx, "Failed to check user pause status", err, map[string]interface{}{"user_id": userID})
184
        return false, contextutils.WrapErrorf(err, "failed to check user pause status for user %d", userID)
185
    }
186

187
3x
    paused := value == "true"
188
3x
    s.logger.Debug(ctx, "User pause status checked", map[string]interface{}{"user_id": userID, "user_paused": paused})
189
3x
    return paused, nil
190
}
191

192
// SetUserPause sets the pause state for a specific user
193
3x
func (s *WorkerService) SetUserPause(ctx context.Context, userID int, paused bool) (err error) {
194
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "set_user_pause", observability.AttributeUserID(userID), attribute.Bool("paused", paused))
195
3x
    defer observability.FinishSpan(span, &err)
196
3x

197
3x
    key := fmt.Sprintf("user_pause_%d", userID)
198
3x
    value := "false"
199
3x
    if paused {
200
2x
        value = "true"
201
2x
    }
202

203
3x
    err = s.SetSetting(ctx, key, value)
204
3x
    if err != nil {
205
        return err
206
    }
207

208
3x
    s.logger.Info(ctx, "User pause state updated", map[string]interface{}{"user_id": userID, "user_paused": paused})
209
3x
    return nil
210
}
211

212
// UpdateWorkerStatus updates the worker status in the database
213
10x
func (s *WorkerService) UpdateWorkerStatus(ctx context.Context, instance string, status *models.WorkerStatus) (err error) {
214
10x
    activity := ""
215
10x
    if status.CurrentActivity.Valid {
216
2x
        activity = status.CurrentActivity.String
217
2x
    }
218

219
10x
    ctx, span := observability.TraceWorkerFunction(ctx, "update_worker_status",
220
10x
        attribute.String("worker.instance", instance),
221
10x
        attribute.Bool("worker.is_running", status.IsRunning),
222
10x
        attribute.Bool("worker.is_paused", status.IsPaused),
223
10x
        attribute.String("worker.activity", activity),
224
10x
    )
225
10x
    defer observability.FinishSpan(span, &err)
226
10x

227
10x
    _, err = s.db.ExecContext(ctx, `
228
10x
        INSERT INTO worker_status (
229
10x
            worker_instance, is_running, is_paused, current_activity,
230
10x
            last_heartbeat, last_run_start, last_run_finish, last_run_error,
231
10x
            total_questions_generated, total_runs, updated_at
232
10x
        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
233
10x
        ON CONFLICT (worker_instance) DO UPDATE SET
234
10x
            is_running = EXCLUDED.is_running,
235
10x
            is_paused = EXCLUDED.is_paused,
236
10x
            current_activity = EXCLUDED.current_activity,
237
10x
            last_heartbeat = EXCLUDED.last_heartbeat,
238
10x
            last_run_start = EXCLUDED.last_run_start,
239
10x
            last_run_finish = EXCLUDED.last_run_finish,
240
10x
            last_run_error = EXCLUDED.last_run_error,
241
10x
            total_questions_generated = EXCLUDED.total_questions_generated,
242
10x
            total_runs = EXCLUDED.total_runs,
243
10x
            updated_at = EXCLUDED.updated_at
244
10x
    `, instance, status.IsRunning, status.IsPaused, status.CurrentActivity,
245
10x
        status.LastHeartbeat, status.LastRunStart, status.LastRunFinish,
246
10x
        status.LastRunError, status.TotalQuestionsGenerated, status.TotalRuns)
247
10x
    if err != nil {
248
        s.logger.Error(ctx, "Failed to update worker status", err, map[string]interface{}{
249
            "worker_instance": instance,
250
            "is_running":      status.IsRunning,
251
            "is_paused":       status.IsPaused,
252
            "activity":        activity,
253
        })
254
        err = contextutils.WrapErrorf(err, "failed to update worker status for instance %s", instance)
255
        return err
256
    }
257

258
10x
    s.logger.Debug(ctx, "Worker status updated", map[string]interface{}{
259
10x
        "worker_instance": instance,
260
10x
        "is_running":      status.IsRunning,
261
10x
        "is_paused":       status.IsPaused,
262
10x
        "activity":        activity,
263
10x
    })
264
10x
    return nil
265
}
266

267
// GetWorkerStatus retrieves worker status by instance
268
6x
func (s *WorkerService) GetWorkerStatus(ctx context.Context, instance string) (result0 *models.WorkerStatus, err error) {
269
6x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_worker_status", attribute.String("worker.instance", instance))
270
6x
    defer observability.FinishSpan(span, &err)
271
6x

272
6x
    var status models.WorkerStatus
273
6x
    err = s.db.QueryRowContext(ctx, `
274
6x
        SELECT id, worker_instance, is_running, is_paused, current_activity,
275
6x
               last_heartbeat, last_run_start, last_run_finish, last_run_error,
276
6x
               total_questions_generated, total_runs, created_at, updated_at
277
6x
        FROM worker_status WHERE worker_instance = $1
278
6x
    `, instance).Scan(
279
6x
        &status.ID, &status.WorkerInstance, &status.IsRunning, &status.IsPaused,
280
6x
        &status.CurrentActivity, &status.LastHeartbeat, &status.LastRunStart,
281
6x
        &status.LastRunFinish, &status.LastRunError, &status.TotalQuestionsGenerated,
282
6x
        &status.TotalRuns, &status.CreatedAt, &status.UpdatedAt,
283
6x
    )
284
6x
    if err != nil {
285
1x
        if err == sql.ErrNoRows {
286
1x
            s.logger.Debug(ctx, "Worker status not found", map[string]interface{}{"worker_instance": instance})
287
1x
            return nil, contextutils.WrapErrorf(err, "worker status not found for instance %s", instance)
288
1x
        }
289
        s.logger.Error(ctx, "Failed to get worker status", err, map[string]interface{}{"worker_instance": instance})
290
        return nil, contextutils.WrapErrorf(err, "failed to get worker status for instance %s", instance)
291
    }
292

293
5x
    return &status, nil
294
}
295

296
// GetAllWorkerStatuses retrieves all worker statuses
297
2x
func (s *WorkerService) GetAllWorkerStatuses(ctx context.Context) (result0 []models.WorkerStatus, err error) {
298
2x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_all_worker_statuses")
299
2x
    defer observability.FinishSpan(span, &err)
300
2x

301
2x
    var rows *sql.Rows
302
2x
    rows, err = s.db.QueryContext(ctx, `
303
2x
        SELECT id, worker_instance, is_running, is_paused, current_activity,
304
2x
               last_heartbeat, last_run_start, last_run_finish, last_run_error,
305
2x
               total_questions_generated, total_runs, created_at, updated_at
306
2x
        FROM worker_status ORDER BY worker_instance
307
2x
    `)
308
2x
    if err != nil {
309
        s.logger.Error(ctx, "Failed to get all worker statuses", err, map[string]interface{}{})
310
        return nil, contextutils.WrapError(err, "failed to get all worker statuses")
311
    }
312
2x
    defer func() {
313
2x
        if err := rows.Close(); err != nil {
314
            s.logger.Error(ctx, "Failed to close rows", err, map[string]interface{}{})
315
        }
316
    }()
317

318
2x
    var statuses []models.WorkerStatus
319
2x
    for rows.Next() {
320
5x
        var status models.WorkerStatus
321
5x
        err = rows.Scan(
322
5x
            &status.ID, &status.WorkerInstance, &status.IsRunning, &status.IsPaused,
323
5x
            &status.CurrentActivity, &status.LastHeartbeat, &status.LastRunStart,
324
5x
            &status.LastRunFinish, &status.LastRunError, &status.TotalQuestionsGenerated,
325
5x
            &status.TotalRuns, &status.CreatedAt, &status.UpdatedAt,
326
5x
        )
327
5x
        if err != nil {
328
            s.logger.Error(ctx, "Failed to scan worker status row", err, map[string]interface{}{})
329
            return nil, contextutils.WrapError(err, "failed to scan worker status row")
330
        }
331
5x
        statuses = append(statuses, status)
332
    }
333

334
2x
    if err := rows.Err(); err != nil {
335
        s.logger.Error(ctx, "Error iterating worker status rows", err, map[string]interface{}{})
336
        return nil, contextutils.WrapError(err, "error iterating worker status rows")
337
    }
338

339
2x
    s.logger.Debug(ctx, "Retrieved all worker statuses", map[string]interface{}{"count": len(statuses)})
340
2x
    return statuses, nil
341
}
342

343
// UpdateHeartbeat updates the heartbeat for a worker instance
344
2x
func (s *WorkerService) UpdateHeartbeat(ctx context.Context, instance string) (err error) {
345
2x
    ctx, span := observability.TraceWorkerFunction(ctx, "update_heartbeat", attribute.String("worker.instance", instance))
346
2x
    defer observability.FinishSpan(span, &err)
347
2x

348
2x
    _, err = s.db.ExecContext(ctx, `
349
2x
        INSERT INTO worker_status (worker_instance, last_heartbeat, updated_at)
350
2x
        VALUES ($1, NOW(), NOW())
351
2x
        ON CONFLICT (worker_instance) DO UPDATE SET
352
2x
            last_heartbeat = EXCLUDED.last_heartbeat,
353
2x
            updated_at = EXCLUDED.updated_at
354
2x
    `, instance)
355
2x
    if err != nil {
356
        s.logger.Error(ctx, "Failed to update heartbeat", err, map[string]interface{}{"worker_instance": instance})
357
        return contextutils.WrapErrorf(err, "failed to update heartbeat for instance %s", instance)
358
    }
359

360
2x
    s.logger.Debug(ctx, "Heartbeat updated", map[string]interface{}{"worker_instance": instance})
361
2x
    return nil
362
}
363

364
// IsWorkerHealthy checks if a worker instance is healthy based on recent heartbeat
365
6x
func (s *WorkerService) IsWorkerHealthy(ctx context.Context, instance string) (result0 bool, err error) {
366
6x
    ctx, span := observability.TraceWorkerFunction(ctx, "is_worker_healthy", attribute.String("worker.instance", instance))
367
6x
    defer observability.FinishSpan(span, &err)
368
6x

369
6x
    var lastHeartbeat sql.NullTime
370
6x
    err = s.db.QueryRowContext(ctx, `
371
6x
        SELECT last_heartbeat FROM worker_status WHERE worker_instance = $1
372
6x
    `, instance).Scan(&lastHeartbeat)
373
6x
    if err != nil {
374
1x
        if err == sql.ErrNoRows {
375
1x
            s.logger.Debug(ctx, "Worker not found, considered unhealthy", map[string]interface{}{"worker_instance": instance})
376
1x
            return false, nil
377
1x
        }
378
        s.logger.Error(ctx, "Failed to check worker health", err, map[string]interface{}{"worker_instance": instance})
379
        return false, contextutils.WrapErrorf(err, "failed to check worker health for instance %s", instance)
380
    }
381

382
5x
    if !lastHeartbeat.Valid {
383
        s.logger.Debug(ctx, "Worker has no heartbeat, considered unhealthy", map[string]interface{}{"worker_instance": instance})
384
        return false, nil
385
    }
386

387
    // Consider worker healthy if heartbeat is within the last 5 minutes
388
5x
    healthy := time.Since(lastHeartbeat.Time) < 5*time.Minute
389
5x
    s.logger.Debug(ctx, "Worker health checked", map[string]interface{}{
390
5x
        "worker_instance": instance,
391
5x
        "healthy":         healthy,
392
5x
        "last_heartbeat":  lastHeartbeat.Time,
393
5x
        "time_since":      time.Since(lastHeartbeat.Time).String(),
394
5x
    })
395
5x
    return healthy, nil
396
}
397

398
// PauseWorker pauses a specific worker instance
399
3x
func (s *WorkerService) PauseWorker(ctx context.Context, instance string) (err error) {
400
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "pause_worker", attribute.String("worker.instance", instance))
401
3x
    defer observability.FinishSpan(span, &err)
402
3x

403
3x
    _, err = s.db.ExecContext(ctx, `
404
3x
        UPDATE worker_status SET is_paused = true, updated_at = NOW()
405
3x
        WHERE worker_instance = $1
406
3x
    `, instance)
407
3x
    if err != nil {
408
        s.logger.Error(ctx, "Failed to pause worker", err, map[string]interface{}{"worker_instance": instance})
409
        return contextutils.WrapErrorf(err, "failed to pause worker instance %s", instance)
410
    }
411

412
3x
    s.logger.Info(ctx, "Worker paused", map[string]interface{}{"worker_instance": instance})
413
3x
    return nil
414
}
415

416
// ResumeWorker resumes a specific worker instance
417
3x
func (s *WorkerService) ResumeWorker(ctx context.Context, instance string) (err error) {
418
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "resume_worker", attribute.String("worker.instance", instance))
419
3x
    defer observability.FinishSpan(span, &err)
420
3x

421
3x
    _, err = s.db.ExecContext(ctx, `
422
3x
        UPDATE worker_status SET is_paused = false, updated_at = NOW()
423
3x
        WHERE worker_instance = $1
424
3x
    `, instance)
425
3x
    if err != nil {
426
        s.logger.Error(ctx, "Failed to resume worker", err, map[string]interface{}{"worker_instance": instance})
427
        return contextutils.WrapErrorf(err, "failed to resume worker instance %s", instance)
428
    }
429

430
3x
    s.logger.Info(ctx, "Worker resumed", map[string]interface{}{"worker_instance": instance})
431
3x
    return nil
432
}
433

434
// GetWorkerHealth returns a map of worker health information
435
1x
func (s *WorkerService) GetWorkerHealth(ctx context.Context) (result0 map[string]interface{}, err error) {
436
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_worker_health")
437
1x
    defer observability.FinishSpan(span, &err)
438
1x

439
1x
    var statuses []models.WorkerStatus
440
1x
    statuses, err = s.GetAllWorkerStatuses(ctx)
441
1x
    if err != nil {
442
        return nil, err
443
    }
444

445
1x
    var globalPaused bool
446
1x
    globalPaused, err = s.IsGlobalPaused(ctx)
447
1x
    if err != nil {
448
        s.logger.Error(ctx, "Failed to get global pause state", err, map[string]interface{}{})
449
        globalPaused = false // Default to false if we can't get the state
450
    }
451

452
1x
    health := make(map[string]interface{})
453
1x
    workerInstances := make([]map[string]interface{}, 0)
454
1x
    healthyCount := 0
455
1x
    totalCount := len(statuses)
456
1x

457
1x
    for _, status := range statuses {
458
3x
        healthy, err := s.IsWorkerHealthy(ctx, status.WorkerInstance)
459
3x
        if err != nil {
460
            s.logger.Error(ctx, "Failed to check health for worker", err, map[string]interface{}{"worker_instance": status.WorkerInstance})
461
            continue
462
        }
463

464
3x
        if healthy {
465
3x
            healthyCount++
466
3x
        }
467

468
3x
        workerInstance := map[string]interface{}{
469
3x
            "worker_instance":           status.WorkerInstance,
470
3x
            "healthy":                   healthy,
471
3x
            "is_running":                status.IsRunning,
472
3x
            "is_paused":                 status.IsPaused,
473
3x
            "last_heartbeat":            status.LastHeartbeat,
474
3x
            "total_questions_generated": status.TotalQuestionsGenerated,
475
3x
            "total_runs":                status.TotalRuns,
476
3x
        }
477
3x
        workerInstances = append(workerInstances, workerInstance)
478
    }
479

480
    // Build comprehensive health summary
481
1x
    health["global_paused"] = globalPaused
482
1x
    health["worker_instances"] = workerInstances
483
1x
    health["total_count"] = totalCount
484
1x
    health["healthy_count"] = healthyCount
485
1x

486
1x
    s.logger.Debug(ctx, "Worker health retrieved", map[string]interface{}{"worker_count": len(health)})
487
1x
    return health, nil
488
}
489

490
// GetHighPriorityTopics returns topics with high average priority scores for a user
491
1x
func (s *WorkerService) GetHighPriorityTopics(ctx context.Context, userID int, language, level, questionType string) (result0 []string, err error) {
492
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_high_priority_topics",
493
1x
        observability.AttributeUserID(userID),
494
1x
        observability.AttributeLanguage(language),
495
1x
        observability.AttributeLevel(level),
496
1x
        attribute.String("question.type", questionType),
497
1x
    )
498
1x
    defer observability.FinishSpan(span, &err)
499
1x

500
1x
    query := `
501
1x
        SELECT q.topic_category, AVG(qps.priority_score) as avg_score
502
1x
        FROM questions q
503
1x
        JOIN user_questions uq ON q.id = uq.question_id
504
1x
        JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
505
1x
        WHERE uq.user_id = $1
506
1x
        AND q.language = $2
507
1x
        AND q.level = $3
508
1x
        AND q.type = $4
509
1x
        AND q.topic_category IS NOT NULL
510
1x
        AND q.topic_category != ''
511
1x
        GROUP BY q.topic_category
512
1x
        HAVING AVG(qps.priority_score) >= 7.0
513
1x
        ORDER BY avg_score DESC
514
1x
        LIMIT 5
515
1x
    `
516
1x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, questionType)
517
1x
    if err != nil {
518
        s.logger.Error(ctx, "Failed to get high priority topics", err, map[string]interface{}{
519
            "user_id": userID, "language": language, "level": level, "question_type": questionType,
520
        })
521
        return nil, contextutils.WrapError(err, "failed to get high priority topics")
522
    }
523
1x
    defer func() {
524
1x
        if err := rows.Close(); err != nil {
525
            s.logger.Error(ctx, "Failed to close rows", err, map[string]interface{}{})
526
        }
527
    }()
528
1x
    var topics []string
529
1x
    for rows.Next() {
530
        var topic string
531
        var avgScore float64
532
        if err := rows.Scan(&topic, &avgScore); err != nil {
533
            s.logger.Error(ctx, "Failed to scan high priority topics row", err, map[string]interface{}{})
534
            return nil, contextutils.WrapError(err, "failed to scan high priority topics row")
535
        }
536
        topics = append(topics, topic)
537
    }
538
1x
    if err := rows.Err(); err != nil {
539
        s.logger.Error(ctx, "Error iterating high priority topics rows", err, map[string]interface{}{})
540
        return nil, contextutils.WrapError(err, "error iterating high priority topics rows")
541
    }
542
1x
    s.logger.Debug(ctx, "Retrieved high priority topics", map[string]interface{}{"user_id": userID, "count": len(topics)})
543
1x
    return topics, nil
544
}
545

546
// GetGapAnalysis identifies areas with poor user performance (knowledge gaps)
547
1x
func (s *WorkerService) GetGapAnalysis(ctx context.Context, userID int, language, level, questionType string) (result0 map[string]int, err error) {
548
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_gap_analysis",
549
1x
        observability.AttributeUserID(userID),
550
1x
        observability.AttributeLanguage(language),
551
1x
        observability.AttributeLevel(level),
552
1x
        attribute.String("question.type", questionType),
553
1x
    )
554
1x
    defer observability.FinishSpan(span, &err)
555
1x

556
1x
    // Query to find areas where user has poor performance (low accuracy)
557
1x
    // This analyzes gaps in user's knowledge across topics and varieties
558
1x
    query := `
559
1x
        WITH user_performance AS (
560
1x
            SELECT
561
1x
                q.topic_category,
562
1x
                q.grammar_focus,
563
1x
                q.vocabulary_domain,
564
1x
                q.scenario,
565
1x
                COUNT(*) as total_questions,
566
1x
                COUNT(CASE WHEN ur.is_correct = true THEN 1 END) as correct_answers,
567
1x
                ROUND(
568
1x
                    COUNT(CASE WHEN ur.is_correct = true THEN 1 END)::decimal / COUNT(*)::decimal * 100, 2
569
1x
                ) as accuracy_percentage
570
1x
            FROM questions q
571
1x
            JOIN user_questions uq ON q.id = uq.question_id
572
1x
            LEFT JOIN user_responses ur ON q.id = ur.question_id AND ur.user_id = $1
573
1x
            WHERE uq.user_id = $1
574
1x
            AND q.language = $2
575
1x
            AND q.level = $3
576
1x
            AND q.type = $4
577
1x
            GROUP BY q.topic_category, q.grammar_focus, q.vocabulary_domain, q.scenario
578
1x
        )
579
1x
        SELECT
580
1x
            COALESCE(topic_category, 'unknown') as area,
581
1x
            'topic' as gap_type,
582
1x
            total_questions,
583
1x
            accuracy_percentage
584
1x
        FROM user_performance
585
1x
        WHERE accuracy_percentage < 60 OR accuracy_percentage IS NULL
586
1x
        UNION ALL
587
1x
        SELECT
588
1x
            COALESCE(grammar_focus, 'unknown') as area,
589
1x
            'grammar' as gap_type,
590
1x
            total_questions,
591
1x
            accuracy_percentage
592
1x
        FROM user_performance
593
1x
        WHERE (accuracy_percentage < 60 OR accuracy_percentage IS NULL) AND grammar_focus IS NOT NULL
594
1x
        UNION ALL
595
1x
        SELECT
596
1x
            COALESCE(vocabulary_domain, 'unknown') as area,
597
1x
            'vocabulary' as gap_type,
598
1x
            total_questions,
599
1x
            accuracy_percentage
600
1x
        FROM user_performance
601
1x
        WHERE (accuracy_percentage < 60 OR accuracy_percentage IS NULL) AND vocabulary_domain IS NOT NULL
602
1x
        UNION ALL
603
1x
        SELECT
604
1x
            COALESCE(scenario, 'unknown') as area,
605
1x
            'scenario' as gap_type,
606
1x
            total_questions,
607
1x
            accuracy_percentage
608
1x
        FROM user_performance
609
1x
        WHERE (accuracy_percentage < 60 OR accuracy_percentage IS NULL) AND scenario IS NOT NULL
610
1x
        ORDER BY accuracy_percentage ASC, total_questions DESC
611
1x
    `
612
1x

613
1x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, questionType)
614
1x
    if err != nil {
615
        s.logger.Error(ctx, "Failed to get gap analysis", err, map[string]interface{}{
616
            "user_id": userID, "language": language, "level": level, "question_type": questionType,
617
        })
618
        return nil, contextutils.WrapError(err, "failed to get gap analysis")
619
    }
620
1x
    defer func() {
621
1x
        if err := rows.Close(); err != nil {
622
            s.logger.Error(ctx, "Failed to close rows", err, map[string]interface{}{})
623
        }
624
    }()
625

626
1x
    gaps := make(map[string]int)
627
1x
    for rows.Next() {
628
1x
        var area, gapType string
629
1x
        var totalQuestions int
630
1x
        var accuracyPercentage sql.NullFloat64
631
1x

632
1x
        if err := rows.Scan(&area, &gapType, &totalQuestions, &accuracyPercentage); err != nil {
633
            s.logger.Error(ctx, "Failed to scan gap analysis row", err, map[string]interface{}{})
634
            return nil, contextutils.WrapError(err, "failed to scan gap analysis row")
635
        }
636

637
        // Create a key that includes the gap type for better identification
638
1x
        key := fmt.Sprintf("%s_%s", gapType, area)
639
1x

640
1x
        // Use the number of questions as the gap severity indicator
641
1x
        // Areas with more questions but poor performance are bigger gaps
642
1x
        gaps[key] = totalQuestions
643
    }
644

645
1x
    if err := rows.Err(); err != nil {
646
        s.logger.Error(ctx, "Error iterating gap analysis rows", err, map[string]interface{}{})
647
        return nil, contextutils.WrapError(err, "error iterating gap analysis rows")
648
    }
649
1x
    s.logger.Debug(ctx, "Retrieved gap analysis", map[string]interface{}{"user_id": userID, "count": len(gaps)})
650
1x
    return gaps, nil
651
}
652

653
// GetPriorityDistribution returns the distribution of priority scores by topic
654
1x
func (s *WorkerService) GetPriorityDistribution(ctx context.Context, userID int, language, level, questionType string) (result0 map[string]int, err error) {
655
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_priority_distribution",
656
1x
        observability.AttributeUserID(userID),
657
1x
        observability.AttributeLanguage(language),
658
1x
        observability.AttributeLevel(level),
659
1x
        attribute.String("question.type", questionType),
660
1x
    )
661
1x
    defer observability.FinishSpan(span, &err)
662
1x

663
1x
    // Query to get priority score distribution by topic
664
1x
    query := `
665
1x
        SELECT q.topic_category, COUNT(*) as question_count
666
1x
        FROM questions q
667
1x
        JOIN user_questions uq ON q.id = uq.question_id
668
1x
        JOIN question_priority_scores qps ON q.id = qps.question_id AND qps.user_id = $1
669
1x
        WHERE uq.user_id = $1
670
1x
        AND q.language = $2
671
1x
        AND q.level = $3
672
1x
        AND q.type = $4
673
1x
        GROUP BY q.topic_category
674
1x
    `
675
1x

676
1x
    rows, err := s.db.QueryContext(ctx, query, userID, language, level, questionType)
677
1x
    if err != nil {
678
        s.logger.Error(ctx, "Failed to get priority distribution", err, map[string]interface{}{
679
            "user_id": userID, "language": language, "level": level, "question_type": questionType,
680
        })
681
        return nil, contextutils.WrapError(err, "failed to get priority distribution")
682
    }
683
1x
    defer func() {
684
1x
        if err := rows.Close(); err != nil {
685
            s.logger.Error(ctx, "Failed to close rows", err, map[string]interface{}{})
686
        }
687
    }()
688

689
1x
    distribution := make(map[string]int)
690
1x
    for rows.Next() {
691
        var topic string
692
        var count int
693
        if err := rows.Scan(&topic, &count); err != nil {
694
            s.logger.Error(ctx, "Failed to scan priority distribution row", err, map[string]interface{}{})
695
            return nil, contextutils.WrapError(err, "failed to scan priority distribution row")
696
        }
697
        distribution[topic] = count
698
    }
699

700
1x
    if err := rows.Err(); err != nil {
701
        s.logger.Error(ctx, "Error iterating priority distribution rows", err, map[string]interface{}{})
702
        return nil, contextutils.WrapError(err, "error iterating priority distribution rows")
703
    }
704
1x
    s.logger.Debug(ctx, "Retrieved priority distribution", map[string]interface{}{"user_id": userID, "count": len(distribution)})
705
1x
    return distribution, nil
706
}
707

708
// GetNotificationStats returns comprehensive notification statistics
709
func (s *WorkerService) GetNotificationStats(ctx context.Context) (result0 map[string]interface{}, err error) {
710
    ctx, span := observability.TraceWorkerFunction(ctx, "get_notification_stats")
711
    defer observability.FinishSpan(span, &err)
712

713
    // Get total notifications sent
714
    var totalSent int
715
    err = s.db.QueryRowContext(ctx, `
716
        SELECT COUNT(*) FROM sent_notifications WHERE status = 'sent'
717
    `).Scan(&totalSent)
718
    if err != nil {
719
        s.logger.Error(ctx, "Failed to get total notifications sent", err, map[string]interface{}{})
720
        return nil, contextutils.WrapError(err, "failed to get total notifications sent")
721
    }
722

723
    // Get total notifications failed
724
    var totalFailed int
725
    err = s.db.QueryRowContext(ctx, `
726
        SELECT COUNT(*) FROM sent_notifications WHERE status = 'failed'
727
    `).Scan(&totalFailed)
728
    if err != nil {
729
        s.logger.Error(ctx, "Failed to get total notifications failed", err, map[string]interface{}{})
730
        return nil, contextutils.WrapError(err, "failed to get total notifications failed")
731
    }
732

733
    // Calculate success rate
734
    var successRate float64
735
    if totalSent+totalFailed > 0 {
736
        successRate = float64(totalSent) / float64(totalSent+totalFailed)
737
    }
738

739
    // Get users with notifications enabled
740
    var usersWithNotifications int
741
    err = s.db.QueryRowContext(ctx, `
742
        SELECT COUNT(DISTINCT user_id) FROM user_learning_preferences WHERE daily_reminder_enabled = true
743
    `).Scan(&usersWithNotifications)
744
    if err != nil {
745
        s.logger.Error(ctx, "Failed to get users with notifications enabled", err, map[string]interface{}{})
746
        return nil, contextutils.WrapError(err, "failed to get users with notifications enabled")
747
    }
748

749
    // Get total users
750
    var totalUsers int
751
    err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&totalUsers)
752
    if err != nil {
753
        s.logger.Error(ctx, "Failed to get total users", err, map[string]interface{}{})
754
        return nil, contextutils.WrapError(err, "failed to get total users")
755
    }
756

757
    // Get notifications sent today
758
    var sentToday int
759
    err = s.db.QueryRowContext(ctx, `
760
        SELECT COUNT(*) FROM sent_notifications
761
        WHERE status = 'sent' AND DATE(sent_at) = CURRENT_DATE
762
    `).Scan(&sentToday)
763
    if err != nil {
764
        s.logger.Error(ctx, "Failed to get notifications sent today", err, map[string]interface{}{})
765
        return nil, contextutils.WrapError(err, "failed to get notifications sent today")
766
    }
767

768
    // Get notifications sent this week
769
    var sentThisWeek int
770
    err = s.db.QueryRowContext(ctx, `
771
        SELECT COUNT(*) FROM sent_notifications
772
        WHERE status = 'sent' AND sent_at >= DATE_TRUNC('week', CURRENT_DATE)
773
    `).Scan(&sentThisWeek)
774
    if err != nil {
775
        s.logger.Error(ctx, "Failed to get notifications sent this week", err, map[string]interface{}{})
776
        return nil, contextutils.WrapError(err, "failed to get notifications sent this week")
777
    }
778

779
    // Get upcoming notifications
780
    var upcomingNotifications int
781
    err = s.db.QueryRowContext(ctx, `
782
        SELECT COUNT(*) FROM upcoming_notifications WHERE status = 'pending'
783
    `).Scan(&upcomingNotifications)
784
    if err != nil {
785
        s.logger.Error(ctx, "Failed to get upcoming notifications", err, map[string]interface{}{})
786
        return nil, contextutils.WrapError(err, "failed to get upcoming notifications")
787
    }
788

789
    // Get unresolved errors
790
    var unresolvedErrors int
791
    err = s.db.QueryRowContext(ctx, `
792
        SELECT COUNT(*) FROM notification_errors WHERE resolved_at IS NULL
793
    `).Scan(&unresolvedErrors)
794
    if err != nil {
795
        s.logger.Error(ctx, "Failed to get unresolved errors", err, map[string]interface{}{})
796
        return nil, contextutils.WrapError(err, "failed to get unresolved errors")
797
    }
798

799
    // Get notifications by type
800
    notificationsByType := make(map[string]int)
801
    rows, err := s.db.QueryContext(ctx, `
802
        SELECT notification_type, COUNT(*)
803
        FROM sent_notifications
804
        WHERE status = 'sent'
805
        GROUP BY notification_type
806
    `)
807
    if err != nil {
808
        s.logger.Error(ctx, "Failed to get notifications by type", err, map[string]interface{}{})
809
        return nil, contextutils.WrapError(err, "failed to get notifications by type")
810
    }
811
    defer func() {
812
        if closeErr := rows.Close(); closeErr != nil {
813
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
814
        }
815
    }()
816

817
    for rows.Next() {
818
        var notificationType string
819
        var count int
820
        if err := rows.Scan(&notificationType, &count); err != nil {
821
            s.logger.Error(ctx, "Failed to scan notifications by type", err, map[string]interface{}{})
822
            return nil, contextutils.WrapError(err, "failed to scan notifications by type")
823
        }
824
        notificationsByType[notificationType] = count
825
    }
826

827
    // Get errors by type
828
    errorsByType := make(map[string]int)
829
    rows, err = s.db.QueryContext(ctx, `
830
        SELECT error_type, COUNT(*)
831
        FROM notification_errors
832
        GROUP BY error_type
833
    `)
834
    if err != nil {
835
        s.logger.Error(ctx, "Failed to get errors by type", err, map[string]interface{}{})
836
        return nil, contextutils.WrapError(err, "failed to get errors by type")
837
    }
838
    defer func() {
839
        if closeErr := rows.Close(); closeErr != nil {
840
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
841
        }
842
    }()
843

844
    for rows.Next() {
845
        var errorType string
846
        var count int
847
        if err := rows.Scan(&errorType, &count); err != nil {
848
            s.logger.Error(ctx, "Failed to scan errors by type", err, map[string]interface{}{})
849
            return nil, contextutils.WrapError(err, "failed to scan errors by type")
850
        }
851
        errorsByType[errorType] = count
852
    }
853

854
    stats := map[string]interface{}{
855
        "total_notifications_sent":         totalSent,
856
        "total_notifications_failed":       totalFailed,
857
        "success_rate":                     successRate,
858
        "users_with_notifications_enabled": usersWithNotifications,
859
        "total_users":                      totalUsers,
860
        "notifications_sent_today":         sentToday,
861
        "notifications_sent_this_week":     sentThisWeek,
862
        "notifications_by_type":            notificationsByType,
863
        "errors_by_type":                   errorsByType,
864
        "upcoming_notifications":           upcomingNotifications,
865
        "unresolved_errors":                unresolvedErrors,
866
    }
867

868
    s.logger.Debug(ctx, "Retrieved notification stats", map[string]interface{}{"stats": stats})
869
    return stats, nil
870
}
871

872
// GetNotificationErrors returns paginated notification errors with filtering
873
func (s *WorkerService) GetNotificationErrors(ctx context.Context, page, pageSize int, errorType, notificationType, resolved string) (result0 []map[string]interface{}, result1, result2 map[string]interface{}, err error) {
874
    ctx, span := observability.TraceWorkerFunction(ctx, "get_notification_errors",
875
        attribute.Int("page", page),
876
        attribute.Int("page_size", pageSize),
877
        attribute.String("error_type", errorType),
878
        attribute.String("notification_type", notificationType),
879
        attribute.String("resolved", resolved),
880
    )
881
    defer observability.FinishSpan(span, &err)
882

883
    // Build WHERE clause
884
    whereConditions := []string{}
885
    args := []interface{}{}
886
    argIndex := 1
887

888
    if errorType != "" {
889
        whereConditions = append(whereConditions, fmt.Sprintf("error_type = $%d", argIndex))
890
        args = append(args, errorType)
891
        argIndex++
892
    }
893

894
    if notificationType != "" {
895
        whereConditions = append(whereConditions, fmt.Sprintf("notification_type = $%d", argIndex))
896
        args = append(args, notificationType)
897
        argIndex++
898
    }
899

900
    switch resolved {
901
    case "true":
902
        whereConditions = append(whereConditions, "resolved_at IS NOT NULL")
903
    case "false":
904
        whereConditions = append(whereConditions, "resolved_at IS NULL")
905
    }
906

907
    whereClause := ""
908
    if len(whereConditions) > 0 {
909
        whereClause = "WHERE " + strings.Join(whereConditions, " AND ")
910
    }
911

912
    // Get total count
913
    var totalErrors int
914
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM notification_errors %s", whereClause)
915
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalErrors)
916
    if err != nil {
917
        s.logger.Error(ctx, "Failed to get total notification errors", err, map[string]interface{}{})
918
        return nil, nil, nil, contextutils.WrapError(err, "failed to get total notification errors")
919
    }
920

921
    // Calculate pagination
922
    offset := (page - 1) * pageSize
923
    totalPages := (totalErrors + pageSize - 1) / pageSize
924

925
    // Get errors with pagination
926
    args = append(args, pageSize, offset)
927
    query := fmt.Sprintf(`
928
        SELECT ne.id, ne.user_id, u.username, ne.notification_type, ne.error_type,
929
               ne.error_message, ne.email_address, ne.occurred_at, ne.resolved_at, ne.resolution_notes
930
        FROM notification_errors ne
931
        LEFT JOIN users u ON ne.user_id = u.id
932
        %s
933
        ORDER BY ne.occurred_at DESC
934
        LIMIT $%d OFFSET $%d
935
    `, whereClause, argIndex, argIndex+1)
936

937
    rows, err := s.db.QueryContext(ctx, query, args...)
938
    if err != nil {
939
        s.logger.Error(ctx, "Failed to get notification errors", err, map[string]interface{}{})
940
        return nil, nil, nil, contextutils.WrapError(err, "failed to get notification errors")
941
    }
942
    defer func() {
943
        if closeErr := rows.Close(); closeErr != nil {
944
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
945
        }
946
    }()
947

948
    var errors []map[string]interface{}
949
    for rows.Next() {
950
        var errorData map[string]interface{}
951
        var id int
952
        var userID sql.NullInt64
953
        var username sql.NullString
954
        var notificationType, errorType, errorMessage string
955
        var emailAddress sql.NullString
956
        var occurredAt time.Time
957
        var resolvedAt sql.NullTime
958
        var resolutionNotes sql.NullString
959

960
        err := rows.Scan(&id, &userID, &username, &notificationType, &errorType, &errorMessage, &emailAddress, &occurredAt, &resolvedAt, &resolutionNotes)
961
        if err != nil {
962
            s.logger.Error(ctx, "Failed to scan notification error", err, map[string]interface{}{})
963
            return nil, nil, nil, contextutils.WrapError(err, "failed to scan notification error")
964
        }
965

966
        errorData = map[string]interface{}{
967
            "id":                id,
968
            "notification_type": notificationType,
969
            "error_type":        errorType,
970
            "error_message":     errorMessage,
971
            "occurred_at":       occurredAt.Format(time.RFC3339),
972
        }
973

974
        if userID.Valid {
975
            errorData["user_id"] = userID.Int64
976
        }
977
        if username.Valid {
978
            errorData["username"] = username.String
979
        }
980
        if emailAddress.Valid {
981
            errorData["email_address"] = emailAddress.String
982
        }
983
        if resolvedAt.Valid {
984
            errorData["resolved_at"] = resolvedAt.Time.Format(time.RFC3339)
985
        }
986
        if resolutionNotes.Valid {
987
            errorData["resolution_notes"] = resolutionNotes.String
988
        }
989

990
        errors = append(errors, errorData)
991
    }
992

993
    // Get stats
994
    stats := map[string]interface{}{
995
        "total_errors":      totalErrors,
996
        "unresolved_errors": 0, // Will be calculated separately
997
    }
998

999
    // Get unresolved errors count
1000
    var unresolvedCount int
1001
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM notification_errors WHERE resolved_at IS NULL").Scan(&unresolvedCount)
1002
    if err != nil {
1003
        s.logger.Error(ctx, "Failed to get unresolved errors count", err, map[string]interface{}{})
1004
    } else {
1005
        stats["unresolved_errors"] = unresolvedCount
1006
    }
1007

1008
    // Get errors by type
1009
    errorsByType := make(map[string]int)
1010
    rows, err = s.db.QueryContext(ctx, "SELECT error_type, COUNT(*) FROM notification_errors GROUP BY error_type")
1011
    if err != nil {
1012
        s.logger.Error(ctx, "Failed to get errors by type", err, map[string]interface{}{})
1013
    } else {
1014
        defer func() {
1015
            if closeErr := rows.Close(); closeErr != nil {
1016
                s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1017
            }
1018
        }()
1019
        for rows.Next() {
1020
            var errorType string
1021
            var count int
1022
            if err := rows.Scan(&errorType, &count); err != nil {
1023
                s.logger.Error(ctx, "Failed to scan errors by type", err, map[string]interface{}{})
1024
                continue
1025
            }
1026
            errorsByType[errorType] = count
1027
        }
1028
        stats["errors_by_type"] = errorsByType
1029
    }
1030

1031
    // Get errors by notification type
1032
    errorsByNotificationType := make(map[string]int)
1033
    rows, err = s.db.QueryContext(ctx, "SELECT notification_type, COUNT(*) FROM notification_errors GROUP BY notification_type")
1034
    if err != nil {
1035
        s.logger.Error(ctx, "Failed to get errors by notification type", err, map[string]interface{}{})
1036
    } else {
1037
        defer func() {
1038
            if closeErr := rows.Close(); closeErr != nil {
1039
                s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1040
            }
1041
        }()
1042
        for rows.Next() {
1043
            var notificationType string
1044
            var count int
1045
            if err := rows.Scan(&notificationType, &count); err != nil {
1046
                s.logger.Error(ctx, "Failed to scan errors by notification type", err, map[string]interface{}{})
1047
                continue
1048
            }
1049
            errorsByNotificationType[notificationType] = count
1050
        }
1051
        stats["errors_by_notification_type"] = errorsByNotificationType
1052
    }
1053

1054
    pagination := map[string]interface{}{
1055
        "page":        page,
1056
        "page_size":   pageSize,
1057
        "total":       totalErrors,
1058
        "total_pages": totalPages,
1059
    }
1060

1061
    s.logger.Debug(ctx, "Retrieved notification errors", map[string]interface{}{
1062
        "count": len(errors), "page": page, "total": totalErrors,
1063
    })
1064

1065
    return errors, pagination, stats, nil
1066
}
1067

1068
// GetUpcomingNotifications returns paginated upcoming notifications with filtering
1069
func (s *WorkerService) GetUpcomingNotifications(ctx context.Context, page, pageSize int, notificationType, status, scheduledAfter, scheduledBefore string) (result0 []map[string]interface{}, result1, result2 map[string]interface{}, err error) {
1070
    ctx, span := observability.TraceWorkerFunction(ctx, "get_upcoming_notifications",
1071
        attribute.Int("page", page),
1072
        attribute.Int("page_size", pageSize),
1073
        attribute.String("notification_type", notificationType),
1074
        attribute.String("status", status),
1075
        attribute.String("scheduled_after", scheduledAfter),
1076
        attribute.String("scheduled_before", scheduledBefore),
1077
    )
1078
    defer observability.FinishSpan(span, &err)
1079

1080
    // Build WHERE clause
1081
    whereConditions := []string{}
1082
    args := []interface{}{}
1083
    argIndex := 1
1084

1085
    if notificationType != "" {
1086
        whereConditions = append(whereConditions, fmt.Sprintf("notification_type = $%d", argIndex))
1087
        args = append(args, notificationType)
1088
        argIndex++
1089
    }
1090

1091
    if status != "" {
1092
        whereConditions = append(whereConditions, fmt.Sprintf("status = $%d", argIndex))
1093
        args = append(args, status)
1094
        argIndex++
1095
    }
1096

1097
    if scheduledAfter != "" {
1098
        whereConditions = append(whereConditions, fmt.Sprintf("scheduled_for >= $%d", argIndex))
1099
        args = append(args, scheduledAfter)
1100
        argIndex++
1101
    }
1102

1103
    if scheduledBefore != "" {
1104
        whereConditions = append(whereConditions, fmt.Sprintf("scheduled_for <= $%d", argIndex))
1105
        args = append(args, scheduledBefore)
1106
        argIndex++
1107
    }
1108

1109
    whereClause := ""
1110
    if len(whereConditions) > 0 {
1111
        whereClause = "WHERE " + strings.Join(whereConditions, " AND ")
1112
    }
1113

1114
    // Get total count
1115
    var totalNotifications int
1116
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM upcoming_notifications %s", whereClause)
1117
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalNotifications)
1118
    if err != nil {
1119
        s.logger.Error(ctx, "Failed to get total upcoming notifications", err, map[string]interface{}{})
1120
        return nil, nil, nil, contextutils.WrapError(err, "failed to get total upcoming notifications")
1121
    }
1122

1123
    // Calculate pagination
1124
    offset := (page - 1) * pageSize
1125
    totalPages := (totalNotifications + pageSize - 1) / pageSize
1126

1127
    // Get notifications with pagination
1128
    args = append(args, pageSize, offset)
1129
    query := fmt.Sprintf(`
1130
        SELECT un.id, un.user_id, u.username, u.email, un.notification_type,
1131
               un.scheduled_for, un.status, un.created_at
1132
        FROM upcoming_notifications un
1133
        LEFT JOIN users u ON un.user_id = u.id
1134
        %s
1135
        ORDER BY un.scheduled_for ASC
1136
        LIMIT $%d OFFSET $%d
1137
    `, whereClause, argIndex, argIndex+1)
1138

1139
    rows, err := s.db.QueryContext(ctx, query, args...)
1140
    if err != nil {
1141
        s.logger.Error(ctx, "Failed to get upcoming notifications", err, map[string]interface{}{})
1142
        return nil, nil, nil, contextutils.WrapError(err, "failed to get upcoming notifications")
1143
    }
1144
    defer func() {
1145
        if closeErr := rows.Close(); closeErr != nil {
1146
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1147
        }
1148
    }()
1149

1150
    var notifications []map[string]interface{}
1151
    for rows.Next() {
1152
        var notification map[string]interface{}
1153
        var id, userID int
1154
        var username, notificationType, status string
1155
        var scheduledFor, createdAt time.Time
1156
        var email sql.NullString
1157

1158
        err := rows.Scan(&id, &userID, &username, &email, &notificationType, &scheduledFor, &status, &createdAt)
1159
        if err != nil {
1160
            s.logger.Error(ctx, "Failed to scan upcoming notification", err, map[string]interface{}{})
1161
            return nil, nil, nil, contextutils.WrapError(err, "failed to scan upcoming notification")
1162
        }
1163

1164
        notification = map[string]interface{}{
1165
            "id":                id,
1166
            "user_id":           userID,
1167
            "username":          username,
1168
            "notification_type": notificationType,
1169
            "scheduled_for":     scheduledFor.Format(time.RFC3339),
1170
            "status":            status,
1171
            "created_at":        createdAt.Format(time.RFC3339),
1172
        }
1173

1174
        if email.Valid {
1175
            notification["email_address"] = email.String
1176
        } else {
1177
            notification["email_address"] = ""
1178
        }
1179

1180
        notifications = append(notifications, notification)
1181
    }
1182

1183
    // Get stats
1184
    stats := map[string]interface{}{
1185
        "total_pending":             0,
1186
        "total_scheduled_today":     0,
1187
        "total_scheduled_this_week": 0,
1188
    }
1189

1190
    // Get total pending
1191
    var totalPending int
1192
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM upcoming_notifications WHERE status = 'pending'").Scan(&totalPending)
1193
    if err != nil {
1194
        s.logger.Error(ctx, "Failed to get total pending", err, map[string]interface{}{})
1195
    } else {
1196
        stats["total_pending"] = totalPending
1197
    }
1198

1199
    // Get scheduled today
1200
    var scheduledToday int
1201
    err = s.db.QueryRowContext(ctx, `
1202
        SELECT COUNT(*) FROM upcoming_notifications
1203
        WHERE status = 'pending' AND DATE(scheduled_for) = CURRENT_DATE
1204
    `).Scan(&scheduledToday)
1205
    if err != nil {
1206
        s.logger.Error(ctx, "Failed to get scheduled today", err, map[string]interface{}{})
1207
    } else {
1208
        stats["total_scheduled_today"] = scheduledToday
1209
    }
1210

1211
    // Get scheduled this week
1212
    var scheduledThisWeek int
1213
    err = s.db.QueryRowContext(ctx, `
1214
        SELECT COUNT(*) FROM upcoming_notifications
1215
        WHERE status = 'pending' AND scheduled_for >= DATE_TRUNC('week', CURRENT_DATE)
1216
    `).Scan(&scheduledThisWeek)
1217
    if err != nil {
1218
        s.logger.Error(ctx, "Failed to get scheduled this week", err, map[string]interface{}{})
1219
    } else {
1220
        stats["total_scheduled_this_week"] = scheduledThisWeek
1221
    }
1222

1223
    // Get notifications by type
1224
    notificationsByType := make(map[string]int)
1225
    rows, err = s.db.QueryContext(ctx, "SELECT notification_type, COUNT(*) FROM upcoming_notifications GROUP BY notification_type")
1226
    if err != nil {
1227
        s.logger.Error(ctx, "Failed to get notifications by type", err, map[string]interface{}{})
1228
    } else {
1229
        defer func() {
1230
            if closeErr := rows.Close(); closeErr != nil {
1231
                s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1232
            }
1233
        }()
1234
        for rows.Next() {
1235
            var notificationType string
1236
            var count int
1237
            if err := rows.Scan(&notificationType, &count); err != nil {
1238
                s.logger.Error(ctx, "Failed to scan notifications by type", err, map[string]interface{}{})
1239
                continue
1240
            }
1241
            notificationsByType[notificationType] = count
1242
        }
1243
        stats["notifications_by_type"] = notificationsByType
1244
    }
1245

1246
    pagination := map[string]interface{}{
1247
        "page":        page,
1248
        "page_size":   pageSize,
1249
        "total":       totalNotifications,
1250
        "total_pages": totalPages,
1251
    }
1252

1253
    s.logger.Debug(ctx, "Retrieved upcoming notifications", map[string]interface{}{
1254
        "count": len(notifications), "page": page, "total": totalNotifications,
1255
    })
1256

1257
    return notifications, pagination, stats, nil
1258
}
1259

1260
// GetSentNotifications returns paginated sent notifications with filtering
1261
func (s *WorkerService) GetSentNotifications(ctx context.Context, page, pageSize int, notificationType, status, sentAfter, sentBefore string) (result0 []map[string]interface{}, result1, result2 map[string]interface{}, err error) {
1262
    ctx, span := observability.TraceWorkerFunction(ctx, "get_sent_notifications",
1263
        attribute.Int("page", page),
1264
        attribute.Int("page_size", pageSize),
1265
        attribute.String("notification_type", notificationType),
1266
        attribute.String("status", status),
1267
        attribute.String("sent_after", sentAfter),
1268
        attribute.String("sent_before", sentBefore),
1269
    )
1270
    defer observability.FinishSpan(span, &err)
1271

1272
    // Build WHERE clause
1273
    whereConditions := []string{}
1274
    args := []interface{}{}
1275
    argIndex := 1
1276

1277
    if notificationType != "" {
1278
        whereConditions = append(whereConditions, fmt.Sprintf("notification_type = $%d", argIndex))
1279
        args = append(args, notificationType)
1280
        argIndex++
1281
    }
1282

1283
    if status != "" {
1284
        whereConditions = append(whereConditions, fmt.Sprintf("status = $%d", argIndex))
1285
        args = append(args, status)
1286
        argIndex++
1287
    }
1288

1289
    if sentAfter != "" {
1290
        whereConditions = append(whereConditions, fmt.Sprintf("sent_at >= $%d", argIndex))
1291
        args = append(args, sentAfter)
1292
        argIndex++
1293
    }
1294

1295
    if sentBefore != "" {
1296
        whereConditions = append(whereConditions, fmt.Sprintf("sent_at <= $%d", argIndex))
1297
        args = append(args, sentBefore)
1298
        argIndex++
1299
    }
1300

1301
    whereClause := ""
1302
    if len(whereConditions) > 0 {
1303
        whereClause = "WHERE " + strings.Join(whereConditions, " AND ")
1304
    }
1305

1306
    // Get total count
1307
    var totalNotifications int
1308
    countQuery := fmt.Sprintf("SELECT COUNT(*) FROM sent_notifications %s", whereClause)
1309
    err = s.db.QueryRowContext(ctx, countQuery, args...).Scan(&totalNotifications)
1310
    if err != nil {
1311
        s.logger.Error(ctx, "Failed to get total sent notifications", err, map[string]interface{}{})
1312
        return nil, nil, nil, contextutils.WrapError(err, "failed to get total sent notifications")
1313
    }
1314

1315
    // Calculate pagination
1316
    offset := (page - 1) * pageSize
1317
    totalPages := (totalNotifications + pageSize - 1) / pageSize
1318

1319
    // Get notifications with pagination
1320
    args = append(args, pageSize, offset)
1321
    query := fmt.Sprintf(`
1322
        SELECT sn.id, sn.user_id, u.username, u.email, sn.notification_type,
1323
               sn.subject, sn.template_name, sn.sent_at, sn.status, sn.error_message, sn.retry_count
1324
        FROM sent_notifications sn
1325
        LEFT JOIN users u ON sn.user_id = u.id
1326
        %s
1327
        ORDER BY sn.sent_at DESC
1328
        LIMIT $%d OFFSET $%d
1329
    `, whereClause, argIndex, argIndex+1)
1330

1331
    rows, err := s.db.QueryContext(ctx, query, args...)
1332
    if err != nil {
1333
        s.logger.Error(ctx, "Failed to get sent notifications", err, map[string]interface{}{})
1334
        return nil, nil, nil, contextutils.WrapError(err, "failed to get sent notifications")
1335
    }
1336
    defer func() {
1337
        if closeErr := rows.Close(); closeErr != nil {
1338
            s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1339
        }
1340
    }()
1341

1342
    var notifications []map[string]interface{}
1343
    for rows.Next() {
1344
        var notification map[string]interface{}
1345
        var id, userID int
1346
        var username, notificationType, subject, templateName, status string
1347
        var sentAt time.Time
1348
        var errorMessage sql.NullString
1349
        var retryCount int
1350
        var email sql.NullString
1351

1352
        err := rows.Scan(&id, &userID, &username, &email, &notificationType, &subject, &templateName, &sentAt, &status, &errorMessage, &retryCount)
1353
        if err != nil {
1354
            s.logger.Error(ctx, "Failed to scan sent notification", err, map[string]interface{}{})
1355
            return nil, nil, nil, contextutils.WrapError(err, "failed to scan sent notification")
1356
        }
1357

1358
        notification = map[string]interface{}{
1359
            "id":                id,
1360
            "user_id":           userID,
1361
            "username":          username,
1362
            "notification_type": notificationType,
1363
            "subject":           subject,
1364
            "template_name":     templateName,
1365
            "sent_at":           sentAt.Format(time.RFC3339),
1366
            "status":            status,
1367
            "retry_count":       retryCount,
1368
        }
1369

1370
        if email.Valid {
1371
            notification["email_address"] = email.String
1372
        } else {
1373
            notification["email_address"] = ""
1374
        }
1375

1376
        if errorMessage.Valid {
1377
            notification["error_message"] = errorMessage.String
1378
        }
1379

1380
        notifications = append(notifications, notification)
1381
    }
1382

1383
    // Get stats
1384
    stats := map[string]interface{}{
1385
        "total_sent":     0,
1386
        "total_failed":   0,
1387
        "success_rate":   0.0,
1388
        "sent_today":     0,
1389
        "sent_this_week": 0,
1390
    }
1391

1392
    // Get total sent
1393
    var totalSent int
1394
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sent_notifications WHERE status = 'sent'").Scan(&totalSent)
1395
    if err != nil {
1396
        s.logger.Error(ctx, "Failed to get total sent", err, map[string]interface{}{})
1397
    } else {
1398
        stats["total_sent"] = totalSent
1399
    }
1400

1401
    // Get total failed
1402
    var totalFailed int
1403
    err = s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sent_notifications WHERE status = 'failed'").Scan(&totalFailed)
1404
    if err != nil {
1405
        s.logger.Error(ctx, "Failed to get total failed", err, map[string]interface{}{})
1406
    } else {
1407
        stats["total_failed"] = totalFailed
1408
    }
1409

1410
    // Calculate success rate
1411
    if totalSent+totalFailed > 0 {
1412
        stats["success_rate"] = float64(totalSent) / float64(totalSent+totalFailed)
1413
    }
1414

1415
    // Get sent today
1416
    var sentToday int
1417
    err = s.db.QueryRowContext(ctx, `
1418
        SELECT COUNT(*) FROM sent_notifications
1419
        WHERE status = 'sent' AND DATE(sent_at) = CURRENT_DATE
1420
    `).Scan(&sentToday)
1421
    if err != nil {
1422
        s.logger.Error(ctx, "Failed to get sent today", err, map[string]interface{}{})
1423
    } else {
1424
        stats["sent_today"] = sentToday
1425
    }
1426

1427
    // Get sent this week
1428
    var sentThisWeek int
1429
    err = s.db.QueryRowContext(ctx, `
1430
        SELECT COUNT(*) FROM sent_notifications
1431
        WHERE status = 'sent' AND sent_at >= DATE_TRUNC('week', CURRENT_DATE)
1432
    `).Scan(&sentThisWeek)
1433
    if err != nil {
1434
        s.logger.Error(ctx, "Failed to get sent this week", err, map[string]interface{}{})
1435
    } else {
1436
        stats["sent_this_week"] = sentThisWeek
1437
    }
1438

1439
    // Get notifications by type
1440
    notificationsByType := make(map[string]int)
1441
    rows, err = s.db.QueryContext(ctx, "SELECT notification_type, COUNT(*) FROM sent_notifications GROUP BY notification_type")
1442
    if err != nil {
1443
        s.logger.Error(ctx, "Failed to get notifications by type", err, map[string]interface{}{})
1444
    } else {
1445
        defer func() {
1446
            if closeErr := rows.Close(); closeErr != nil {
1447
                s.logger.Error(ctx, "Failed to close rows", closeErr, map[string]interface{}{})
1448
            }
1449
        }()
1450
        for rows.Next() {
1451
            var notificationType string
1452
            var count int
1453
            if err := rows.Scan(&notificationType, &count); err != nil {
1454
                s.logger.Error(ctx, "Failed to scan notifications by type", err, map[string]interface{}{})
1455
                continue
1456
            }
1457
            notificationsByType[notificationType] = count
1458
        }
1459
        stats["notifications_by_type"] = notificationsByType
1460
    }
1461

1462
    pagination := map[string]interface{}{
1463
        "page":        page,
1464
        "page_size":   pageSize,
1465
        "total":       totalNotifications,
1466
        "total_pages": totalPages,
1467
    }
1468

1469
    s.logger.Debug(ctx, "Retrieved sent notifications", map[string]interface{}{
1470
        "count": len(notifications), "page": page, "total": totalNotifications,
1471
    })
1472

1473
    return notifications, pagination, stats, nil
1474
}
1475

1476
// CreateTestSentNotification creates a test sent notification for testing purposes
1477
func (s *WorkerService) CreateTestSentNotification(ctx context.Context, userID int, notificationType, subject, templateName, status, errorMessage string) error {
1478
    ctx, span := observability.TraceWorkerFunction(ctx, "create_test_sent_notification",
1479
        attribute.Int("user.id", userID),
1480
        attribute.String("notification.type", notificationType),
1481
        attribute.String("notification.status", status),
1482
    )
1483
    defer span.End()
1484

1485
    query := `
1486
        INSERT INTO sent_notifications (user_id, notification_type, subject, template_name, sent_at, status, error_message)
1487
        VALUES ($1, $2, $3, $4, $5, $6, $7)
1488
    `
1489

1490
    _, err := s.db.ExecContext(ctx, query, userID, notificationType, subject, templateName, time.Now(), status, errorMessage)
1491
    if err != nil {
1492
        span.RecordError(err)
1493
        s.logger.Error(ctx, "Failed to create test sent notification", err, map[string]interface{}{
1494
            "user_id":           userID,
1495
            "notification_type": notificationType,
1496
            "status":            status,
1497
        })
1498
        return contextutils.WrapError(err, "failed to create test sent notification")
1499
    }
1500

1501
    s.logger.Info(ctx, "Created test sent notification", map[string]interface{}{
1502
        "user_id":           userID,
1503
        "notification_type": notificationType,
1504
        "status":            status,
1505
    })
1506

1507
    return nil
1508
}
1509


			
quizapp internal utils
70.8%
Statements
143/202
errors.go
86.7%
52/60
localization.go
67.5%
56/83
security.go
100.0%
5/5
time.go
54.7%
29/53
validation.go
100.0%
1/1
quizapp internal utils validation.go
86.7%
Statements
52/60
1
// Package contextutils provides error handling utilities and standardized error types
2
// for consistent error management across the quiz application.
3
package contextutils
4

5
import (
6
    "fmt"
7
    "strings"
8
)
9

10
// ErrorCode represents a standardized error code for API responses
11
type ErrorCode string
12

13
const (
14
    // Database error codes
15

16
    // ErrorCodeDatabaseConnection indicates a database connection error
17
    ErrorCodeDatabaseConnection ErrorCode = "DATABASE_CONNECTION_ERROR"
18
    // ErrorCodeDatabaseQuery indicates a database query error
19
    ErrorCodeDatabaseQuery ErrorCode = "DATABASE_QUERY_ERROR"
20
    // ErrorCodeDatabaseTransaction indicates a database transaction error
21
    ErrorCodeDatabaseTransaction ErrorCode = "DATABASE_TRANSACTION_ERROR"
22
    // ErrorCodeRecordNotFound indicates that a requested record was not found
23
    ErrorCodeRecordNotFound ErrorCode = "RECORD_NOT_FOUND"
24
    // ErrorCodeRecordExists indicates that a record already exists (duplicate key)
25
    ErrorCodeRecordExists ErrorCode = "RECORD_ALREADY_EXISTS"
26
    // ErrorCodeForeignKeyViolation indicates a foreign key constraint violation
27
    ErrorCodeForeignKeyViolation ErrorCode = "FOREIGN_KEY_VIOLATION"
28

29
    // Validation error codes
30

31
    // ErrorCodeInvalidInput indicates that the provided input is invalid
32
    ErrorCodeInvalidInput ErrorCode = "INVALID_INPUT"
33
    // ErrorCodeMissingRequired indicates that a required field is missing
34
    ErrorCodeMissingRequired ErrorCode = "MISSING_REQUIRED_FIELD"
35
    // ErrorCodeInvalidFormat indicates that the input format is invalid
36
    ErrorCodeInvalidFormat ErrorCode = "INVALID_FORMAT"
37
    // ErrorCodeValidationFailed indicates that validation has failed
38
    ErrorCodeValidationFailed ErrorCode = "VALIDATION_FAILED"
39

40
    // Authentication error codes
41

42
    // ErrorCodeUnauthorized indicates that the user is not authorized
43
    ErrorCodeUnauthorized ErrorCode = "UNAUTHORIZED"
44
    // ErrorCodeForbidden indicates that the user is forbidden from accessing the resource
45
    ErrorCodeForbidden ErrorCode = "FORBIDDEN"
46
    // ErrorCodeInvalidCredentials indicates that the provided credentials are invalid
47
    ErrorCodeInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
48
    // ErrorCodeSessionExpired indicates that the user session has expired
49
    ErrorCodeSessionExpired ErrorCode = "SESSION_EXPIRED"
50

51
    // Service error codes
52

53
    // ErrorCodeServiceUnavailable indicates that the service is temporarily unavailable
54
    ErrorCodeServiceUnavailable ErrorCode = "SERVICE_UNAVAILABLE"
55
    // ErrorCodeTimeout indicates that a request has timed out
56
    ErrorCodeTimeout ErrorCode = "REQUEST_TIMEOUT"
57
    // ErrorCodeRateLimit indicates that the rate limit has been exceeded
58
    ErrorCodeRateLimit ErrorCode = "RATE_LIMIT_EXCEEDED"
59
    // ErrorCodeInternalError indicates an internal server error
60
    ErrorCodeInternalError ErrorCode = "INTERNAL_SERVER_ERROR"
61
    // ErrorCodeAssignmentNotFound indicates that a question assignment was not found
62
    ErrorCodeAssignmentNotFound ErrorCode = "ASSIGNMENT_NOT_FOUND"
63

64
    // Question error codes
65

66
    // ErrorCodeTimestampMissingTimezone indicates that a timestamp is missing timezone information
67
    ErrorCodeTimestampMissingTimezone ErrorCode = "TIMESTAMP_MISSING_TIMEZONE"
68
    // ErrorCodeNoQuestionsAvailable indicates that no questions are available
69
    ErrorCodeNoQuestionsAvailable ErrorCode = "NO_QUESTIONS_AVAILABLE"
70
    // ErrorCodeQuestionAlreadyAnswered indicates that the question has already been answered
71
    ErrorCodeQuestionAlreadyAnswered ErrorCode = "QUESTION_ALREADY_ANSWERED"
72
    // ErrorCodeQuestionNotFound indicates that the requested question was not found
73
    ErrorCodeQuestionNotFound ErrorCode = "QUESTION_NOT_FOUND"
74
    // ErrorCodeInvalidAnswerIndex indicates that the answer index is invalid
75
    ErrorCodeInvalidAnswerIndex ErrorCode = "INVALID_ANSWER_INDEX"
76

77
    // AI Service error codes
78

79
    // ErrorCodeAIProviderUnavailable indicates that the AI provider is unavailable
80
    ErrorCodeAIProviderUnavailable ErrorCode = "AI_PROVIDER_UNAVAILABLE"
81
    // ErrorCodeAIRequestFailed indicates that the AI request failed
82
    ErrorCodeAIRequestFailed ErrorCode = "AI_REQUEST_FAILED"
83
    // ErrorCodeAIResponseInvalid indicates that the AI response is invalid
84
    ErrorCodeAIResponseInvalid ErrorCode = "AI_RESPONSE_INVALID"
85
    // ErrorCodeAIConfigInvalid indicates that the AI configuration is invalid
86
    ErrorCodeAIConfigInvalid ErrorCode = "AI_CONFIG_INVALID"
87

88
    // OAuth error codes
89

90
    // ErrorCodeOAuthCodeExpired indicates that the OAuth authorization code has expired
91
    ErrorCodeOAuthCodeExpired ErrorCode = "OAUTH_CODE_EXPIRED"
92
    // ErrorCodeOAuthStateMismatch indicates that the OAuth state parameter does not match
93
    ErrorCodeOAuthStateMismatch ErrorCode = "OAUTH_STATE_MISMATCH"
94
    // ErrorCodeOAuthProviderError indicates an error from the OAuth provider
95
    ErrorCodeOAuthProviderError ErrorCode = "OAUTH_PROVIDER_ERROR"
96
)
97

98
// SeverityLevel represents the severity of an error for logging and monitoring
99
type SeverityLevel string
100

101
const (
102
    // SeverityDebug indicates debug-level errors for development
103
    SeverityDebug SeverityLevel = "debug"
104
    // SeverityInfo indicates informational errors
105
    SeverityInfo SeverityLevel = "info"
106
    // SeverityWarn indicates warning-level errors
107
    SeverityWarn SeverityLevel = "warn"
108
    // SeverityError indicates error-level issues
109
    SeverityError SeverityLevel = "error"
110
    // SeverityFatal indicates fatal errors that require immediate attention
111
    SeverityFatal SeverityLevel = "fatal"
112
)
113

114
// AppError represents a structured error with code, severity, and context
115
type AppError struct {
116
    Code     ErrorCode
117
    Severity SeverityLevel
118
    Message  string
119
    Details  string
120
    Cause    error
121
}
122

123
// Error implements the error interface
124
3x
func (e *AppError) Error() string {
125
3x
    if e.Details != "" {
126
1x
        return fmt.Sprintf("%s: %s - %s", e.Code, e.Message, e.Details)
127
1x
    }
128
2x
    return fmt.Sprintf("%s: %s", e.Code, e.Message)
129
}
130

131
// Unwrap returns the underlying cause error
132
1x
func (e *AppError) Unwrap() error {
133
1x
    return e.Cause
134
1x
}
135

136
// Is implements error comparison for errors.Is
137
3x
func (e *AppError) Is(target error) bool {
138
3x
    if appErr, ok := target.(*AppError); ok {
139
2x
        return e.Code == appErr.Code
140
2x
    }
141
1x
    return false
142
}
143

144
// Error types for consistent error handling with associated codes and severity
145
var (
146
    // Database errors
147
    ErrDatabaseConnection = &AppError{
148
        Code:     ErrorCodeDatabaseConnection,
149
        Severity: SeverityError,
150
        Message:  "Database connection failed",
151
    }
152

153
    ErrDatabaseQuery = &AppError{
154
        Code:     ErrorCodeDatabaseQuery,
155
        Severity: SeverityError,
156
        Message:  "Database query failed",
157
    }
158

159
    ErrDatabaseTransaction = &AppError{
160
        Code:     ErrorCodeDatabaseTransaction,
161
        Severity: SeverityError,
162
        Message:  "Database transaction failed",
163
    }
164

165
    ErrRecordNotFound = &AppError{
166
        Code:     ErrorCodeRecordNotFound,
167
        Severity: SeverityInfo,
168
        Message:  "Record not found",
169
    }
170

171
    ErrRecordExists = &AppError{
172
        Code:     ErrorCodeRecordExists,
173
        Severity: SeverityInfo,
174
        Message:  "Record already exists",
175
    }
176

177
    ErrForeignKeyViolation = &AppError{
178
        Code:     ErrorCodeForeignKeyViolation,
179
        Severity: SeverityError,
180
        Message:  "Foreign key constraint violation",
181
    }
182

183
    // Validation errors
184
    ErrInvalidInput = &AppError{
185
        Code:     ErrorCodeInvalidInput,
186
        Severity: SeverityWarn,
187
        Message:  "Invalid input",
188
    }
189

190
    ErrMissingRequired = &AppError{
191
        Code:     ErrorCodeMissingRequired,
192
        Severity: SeverityWarn,
193
        Message:  "Missing required field",
194
    }
195

196
    ErrInvalidFormat = &AppError{
197
        Code:     ErrorCodeInvalidFormat,
198
        Severity: SeverityWarn,
199
        Message:  "Invalid format",
200
    }
201

202
    ErrValidationFailed = &AppError{
203
        Code:     ErrorCodeValidationFailed,
204
        Severity: SeverityWarn,
205
        Message:  "Validation failed",
206
    }
207

208
    // Authentication errors
209
    ErrUnauthorized = &AppError{
210
        Code:     ErrorCodeUnauthorized,
211
        Severity: SeverityWarn,
212
        Message:  "Unauthorized",
213
    }
214

215
    ErrForbidden = &AppError{
216
        Code:     ErrorCodeForbidden,
217
        Severity: SeverityWarn,
218
        Message:  "Forbidden",
219
    }
220

221
    ErrInvalidCredentials = &AppError{
222
        Code:     ErrorCodeInvalidCredentials,
223
        Severity: SeverityWarn,
224
        Message:  "Invalid credentials",
225
    }
226

227
    ErrSessionExpired = &AppError{
228
        Code:     ErrorCodeSessionExpired,
229
        Severity: SeverityInfo,
230
        Message:  "Session expired",
231
    }
232

233
    // Service errors
234
    ErrServiceUnavailable = &AppError{
235
        Code:     ErrorCodeServiceUnavailable,
236
        Severity: SeverityError,
237
        Message:  "Service unavailable",
238
    }
239

240
    ErrTimeout = &AppError{
241
        Code:     ErrorCodeTimeout,
242
        Severity: SeverityWarn,
243
        Message:  "Request timeout",
244
    }
245

246
    ErrRateLimit = &AppError{
247
        Code:     ErrorCodeRateLimit,
248
        Severity: SeverityWarn,
249
        Message:  "Rate limit exceeded",
250
    }
251

252
    ErrInternalError = &AppError{
253
        Code:     ErrorCodeInternalError,
254
        Severity: SeverityError,
255
        Message:  "Internal server error",
256
    }
257

258
    ErrAssignmentNotFound = &AppError{
259
        Code:     ErrorCodeAssignmentNotFound,
260
        Severity: SeverityInfo,
261
        Message:  "Assignment not found",
262
    }
263

264
    // Question errors
265
    ErrTimestampMissingTimezone = &AppError{
266
        Code:     ErrorCodeTimestampMissingTimezone,
267
        Severity: SeverityError,
268
        Message:  "Timestamp missing timezone",
269
    }
270

271
    ErrNoQuestionsAvailable = &AppError{
272
        Code:     ErrorCodeNoQuestionsAvailable,
273
        Severity: SeverityInfo,
274
        Message:  "No questions available for assignment",
275
    }
276

277
    ErrQuestionAlreadyAnswered = &AppError{
278
        Code:     ErrorCodeQuestionAlreadyAnswered,
279
        Severity: SeverityInfo,
280
        Message:  "Question already answered",
281
    }
282

283
    ErrQuestionNotFound = &AppError{
284
        Code:     ErrorCodeQuestionNotFound,
285
        Severity: SeverityInfo,
286
        Message:  "Question not found",
287
    }
288

289
    ErrInvalidAnswerIndex = &AppError{
290
        Code:     ErrorCodeInvalidAnswerIndex,
291
        Severity: SeverityWarn,
292
        Message:  "Invalid answer index",
293
    }
294

295
    // AI Service errors
296
    ErrAIProviderUnavailable = &AppError{
297
        Code:     ErrorCodeAIProviderUnavailable,
298
        Severity: SeverityError,
299
        Message:  "AI provider unavailable",
300
    }
301

302
    ErrAIRequestFailed = &AppError{
303
        Code:     ErrorCodeAIRequestFailed,
304
        Severity: SeverityError,
305
        Message:  "AI request failed",
306
    }
307

308
    ErrAIResponseInvalid = &AppError{
309
        Code:     ErrorCodeAIResponseInvalid,
310
        Severity: SeverityError,
311
        Message:  "AI response invalid",
312
    }
313

314
    ErrAIConfigInvalid = &AppError{
315
        Code:     ErrorCodeAIConfigInvalid,
316
        Severity: SeverityError,
317
        Message:  "AI configuration invalid",
318
    }
319

320
    // OAuth errors
321
    ErrOAuthCodeExpired = &AppError{
322
        Code:     ErrorCodeOAuthCodeExpired,
323
        Severity: SeverityWarn,
324
        Message:  "OAuth code expired",
325
    }
326

327
    ErrOAuthStateMismatch = &AppError{
328
        Code:     ErrorCodeOAuthStateMismatch,
329
        Severity: SeverityError,
330
        Message:  "OAuth state mismatch",
331
    }
332

333
    ErrOAuthProviderError = &AppError{
334
        Code:     ErrorCodeOAuthProviderError,
335
        Severity: SeverityError,
336
        Message:  "OAuth provider error",
337
    }
338
)
339

340
// NewAppError creates a new AppError with the specified code, severity, message and details
341
1x
func NewAppError(code ErrorCode, severity SeverityLevel, message, details string) *AppError {
342
1x
    return &AppError{
343
1x
        Code:     code,
344
1x
        Severity: severity,
345
1x
        Message:  message,
346
1x
        Details:  details,
347
1x
    }
348
1x
}
349

350
// NewAppErrorWithCause creates a new AppError with an underlying cause
351
1x
func NewAppErrorWithCause(code ErrorCode, severity SeverityLevel, message, details string, cause error) *AppError {
352
1x
    return &AppError{
353
1x
        Code:     code,
354
1x
        Severity: severity,
355
1x
        Message:  message,
356
1x
        Details:  details,
357
1x
        Cause:    cause,
358
1x
    }
359
1x
}
360

361
// WrapError wraps an error with additional context, preserving AppError structure if possible
362
4x
func WrapError(err error, context string) error {
363
4x
    if err == nil {
364
1x
        return nil
365
1x
    }
366

367
    // If it's already an AppError, wrap it with additional details
368
3x
    if appErr, ok := err.(*AppError); ok {
369
1x
        return &AppError{
370
1x
            Code:     appErr.Code,
371
1x
            Severity: appErr.Severity,
372
1x
            Message:  context,
373
1x
            Details:  appErr.Error(),
374
1x
            Cause:    appErr,
375
1x
        }
376
1x
    }
377

378
    // For regular errors, create a generic internal error wrapper
379
2x
    return &AppError{
380
2x
        Code:     ErrorCodeInternalError,
381
2x
        Severity: SeverityError,
382
2x
        Message:  context,
383
2x
        Details:  err.Error(),
384
2x
        Cause:    err,
385
2x
    }
386
}
387

388
// WrapErrorf wraps an error with formatted context, preserving AppError structure if possible
389
1x
func WrapErrorf(err error, format string, args ...interface{}) error {
390
1x
    if err == nil {
391
        return nil
392
    }
393

394
    // Handle %w verb for error wrapping by using fmt.Errorf
395
1x
    if strings.Contains(format, "%w") {
396
        // Use fmt.Errorf to properly handle %w verb
397
        wrappedErr := fmt.Errorf(format, args...)
398

399
        // If it's already an AppError, wrap it with the formatted message
400
        if appErr, ok := err.(*AppError); ok {
401
            return &AppError{
402
                Code:     appErr.Code,
403
                Severity: appErr.Severity,
404
                Message:  wrappedErr.Error(),
405
                Details:  appErr.Error(),
406
                Cause:    appErr,
407
            }
408
        }
409

410
        // For regular errors, wrap with the formatted error
411
        return &AppError{
412
            Code:     ErrorCodeInternalError,
413
            Severity: SeverityError,
414
            Message:  wrappedErr.Error(),
415
            Details:  err.Error(),
416
            Cause:    err,
417
        }
418
    }
419

420
    // If it's already an AppError, wrap it with additional details
421
1x
    if appErr, ok := err.(*AppError); ok {
422
        context := fmt.Sprintf(format, args...)
423
        return &AppError{
424
            Code:     appErr.Code,
425
            Severity: appErr.Severity,
426
            Message:  context,
427
            Details:  appErr.Error(),
428
            Cause:    appErr,
429
        }
430
    }
431

432
    // For regular errors, create a generic internal error wrapper
433
1x
    context := fmt.Sprintf(format, args...)
434
1x
    return &AppError{
435
1x
        Code:     ErrorCodeInternalError,
436
1x
        Severity: SeverityError,
437
1x
        Message:  context,
438
1x
        Details:  err.Error(),
439
1x
        Cause:    err,
440
1x
    }
441
}
442

443
// ErrorWithContextf creates a new error with formatted context
444
1x
func ErrorWithContextf(format string, args ...interface{}) error {
445
1x
    return &AppError{
446
1x
        Code:     ErrorCodeInternalError,
447
1x
        Severity: SeverityError,
448
1x
        Message:  fmt.Sprintf(format, args...),
449
1x
    }
450
1x
}
451

452
// IsError checks if an error matches a specific AppError type
453
3x
func IsError(err error, target *AppError) bool {
454
3x
    if appErr, ok := err.(*AppError); ok {
455
2x
        return appErr.Code == target.Code
456
2x
    }
457
1x
    return false
458
}
459

460
// AsError attempts to convert an error to an AppError
461
2x
func AsError(err error, target **AppError) bool {
462
2x
    if appErr, ok := err.(*AppError); ok {
463
1x
        *target = appErr
464
1x
        return true
465
1x
    }
466
1x
    return false
467
}
468

469
// GetErrorCode returns the error code from an error if it's an AppError, otherwise returns a default code
470
2x
func GetErrorCode(err error) ErrorCode {
471
2x
    if appErr, ok := err.(*AppError); ok {
472
1x
        return appErr.Code
473
1x
    }
474
1x
    return ErrorCodeInternalError
475
}
476

477
// GetErrorSeverity returns the severity level from an error if it's an AppError, otherwise returns error
478
2x
func GetErrorSeverity(err error) SeverityLevel {
479
2x
    if appErr, ok := err.(*AppError); ok {
480
1x
        return appErr.Severity
481
1x
    }
482
1x
    return SeverityError
483
}
484

485
// IsRetryable determines if an error should be retried based on its type and severity
486
8x
func IsRetryable(err error) bool {
487
8x
    if appErr, ok := err.(*AppError); ok {
488
7x
        // Only retry certain types of errors that are likely transient
489
7x
        switch appErr.Code {
490
4x
        case ErrorCodeTimeout, ErrorCodeServiceUnavailable, ErrorCodeDatabaseConnection:
491
4x
            return appErr.Severity != SeverityFatal
492
        }
493
    }
494
4x
    return false
495
}
496

497
// GetErrorLocalizedMessage returns a localized message for the error
498
3x
func GetErrorLocalizedMessage(err error, locale string) string {
499
3x
    if appErr, ok := err.(*AppError); ok {
500
2x
        return GetLocalizedMessageWithDetails(appErr.Code, ParseLocale(locale), appErr.Details)
501
2x
    }
502
1x
    return "An error occurred"
503
}
504

505
// ToJSON converts an AppError to a JSON-serializable structure for API responses
506
2x
func (e *AppError) ToJSON() map[string]interface{} {
507
2x
    result := map[string]interface{}{
508
2x
        "code":     string(e.Code),
509
2x
        "message":  e.Message,
510
2x
        "severity": string(e.Severity),
511
2x
        "error":    e.Message, // Include error field for backward compatibility
512
2x
    }
513
2x

514
2x
    if e.Details != "" {
515
2x
        result["details"] = e.Details
516
2x
    }
517

518
    // Add retryable information
519
2x
    result["retryable"] = IsRetryable(e)
520
2x

521
2x
    if e.Cause != nil {
522
1x
        // Only include cause in debug mode or for certain error types
523
1x
        switch e.Severity {
524
        case SeverityError, SeverityFatal:
525
            result["cause"] = e.Cause.Error()
526
        }
527
    }
528

529
2x
    return result
530
}
531

532
// ToJSONWithLocale converts an AppError to a JSON-serializable structure with localized messages
533
1x
func (e *AppError) ToJSONWithLocale(locale string) map[string]interface{} {
534
1x
    result := e.ToJSON()
535
1x
    // Replace the message with localized version and update error field too
536
1x
    localizedMessage := GetLocalizedMessage(e.Code, ParseLocale(locale))
537
1x
    result["message"] = localizedMessage
538
1x
    result["error"] = localizedMessage // Keep error field in sync
539
1x
    return result
540
1x
}
541


			
quizapp internal utils validation.go
67.5%
Statements
56/83
1
package contextutils
2

3
import (
4
    "encoding/json"
5
    "fmt"
6
    "strings"
7
)
8

9
// Locale represents a language locale (e.g., "en", "es", "fr")
10
type Locale string
11

12
const (
13
    // LocaleEnglish represents English language
14
    LocaleEnglish Locale = "en"
15
    // LocaleSpanish represents Spanish language
16
    LocaleSpanish Locale = "es"
17
    // LocaleFrench represents French language
18
    LocaleFrench Locale = "fr"
19
    // LocaleGerman represents German language
20
    LocaleGerman Locale = "de"
21
    // LocaleItalian represents Italian language
22
    LocaleItalian Locale = "it"
23
)
24

25
// LocalizedMessages contains localized error messages for different locales
26
type LocalizedMessages struct {
27
    messages map[ErrorCode]map[Locale]string
28
}
29

30
// NewLocalizedMessages creates a new instance of localized messages
31
7x
func NewLocalizedMessages() *LocalizedMessages {
32
7x
    return &LocalizedMessages{
33
7x
        messages: make(map[ErrorCode]map[Locale]string),
34
7x
    }
35
7x
}
36

37
// AddMessage adds a localized message for a specific error code and locale
38
23x
func (lm *LocalizedMessages) AddMessage(code ErrorCode, locale Locale, message string) {
39
23x
    if lm.messages[code] == nil {
40
11x
        lm.messages[code] = make(map[Locale]string)
41
11x
    }
42
23x
    lm.messages[code][locale] = message
43
}
44

45
// GetMessage returns the localized message for an error code and locale
46
16x
func (lm *LocalizedMessages) GetMessage(code ErrorCode, locale Locale) string {
47
16x
    // Try to get the message for the specific locale
48
16x
    if localeMessages, exists := lm.messages[code]; exists {
49
15x
        if message, exists := localeMessages[locale]; exists {
50
12x
            return message
51
12x
        }
52

53
        // Fallback to English if the specific locale doesn't have a message
54
3x
        if message, exists := localeMessages[LocaleEnglish]; exists {
55
1x
            return message
56
1x
        }
57
    }
58

59
    // Fallback to a default message
60
3x
    return getDefaultMessage(code)
61
}
62

63
// GetMessageWithDetails returns a localized message with additional details
64
4x
func (lm *LocalizedMessages) GetMessageWithDetails(code ErrorCode, locale Locale, details string) string {
65
4x
    message := lm.GetMessage(code, locale)
66
4x
    if details != "" {
67
3x
        return fmt.Sprintf("%s: %s", message, details)
68
3x
    }
69
1x
    return message
70
}
71

72
// getDefaultMessage returns a default English message for error codes
73
8x
func getDefaultMessage(code ErrorCode) string {
74
8x
    switch code {
75
    case ErrorCodeDatabaseConnection:
76
        return "Database connection failed"
77
    case ErrorCodeDatabaseQuery:
78
        return "Database query failed"
79
    case ErrorCodeDatabaseTransaction:
80
        return "Database transaction failed"
81
1x
    case ErrorCodeRecordNotFound:
82
1x
        return "Record not found"
83
    case ErrorCodeRecordExists:
84
        return "Record already exists"
85
    case ErrorCodeForeignKeyViolation:
86
        return "Foreign key constraint violation"
87
3x
    case ErrorCodeInvalidInput:
88
3x
        return "Invalid input"
89
    case ErrorCodeMissingRequired:
90
        return "Missing required field"
91
    case ErrorCodeInvalidFormat:
92
        return "Invalid format"
93
    case ErrorCodeValidationFailed:
94
        return "Validation failed"
95
1x
    case ErrorCodeUnauthorized:
96
1x
        return "Unauthorized access"
97
    case ErrorCodeForbidden:
98
        return "Access forbidden"
99
    case ErrorCodeInvalidCredentials:
100
        return "Invalid credentials"
101
    case ErrorCodeSessionExpired:
102
        return "Session expired"
103
    case ErrorCodeServiceUnavailable:
104
        return "Service temporarily unavailable"
105
    case ErrorCodeTimeout:
106
        return "Request timeout"
107
    case ErrorCodeRateLimit:
108
        return "Rate limit exceeded"
109
1x
    case ErrorCodeInternalError:
110
1x
        return "Internal server error"
111
    case ErrorCodeAssignmentNotFound:
112
        return "Assignment not found"
113
    case ErrorCodeTimestampMissingTimezone:
114
        return "Timestamp missing timezone"
115
    case ErrorCodeNoQuestionsAvailable:
116
        return "No questions available"
117
    case ErrorCodeQuestionAlreadyAnswered:
118
        return "Question already answered"
119
    case ErrorCodeQuestionNotFound:
120
        return "Question not found"
121
    case ErrorCodeInvalidAnswerIndex:
122
        return "Invalid answer index"
123
    case ErrorCodeAIProviderUnavailable:
124
        return "AI service unavailable"
125
    case ErrorCodeAIRequestFailed:
126
        return "AI request failed"
127
    case ErrorCodeAIResponseInvalid:
128
        return "AI response invalid"
129
    case ErrorCodeAIConfigInvalid:
130
        return "AI configuration invalid"
131
    case ErrorCodeOAuthCodeExpired:
132
        return "OAuth code expired"
133
    case ErrorCodeOAuthStateMismatch:
134
        return "OAuth state mismatch"
135
    case ErrorCodeOAuthProviderError:
136
        return "OAuth provider error"
137
2x
    default:
138
2x
        return "An error occurred"
139
    }
140
}
141

142
// LoadMessagesFromJSON loads localized messages from a JSON structure
143
2x
func (lm *LocalizedMessages) LoadMessagesFromJSON(jsonData string) error {
144
2x
    var data map[string]map[string]string
145
2x
    if err := json.Unmarshal([]byte(jsonData), &data); err != nil {
146
1x
        return WrapError(err, "failed to parse localization JSON")
147
1x
    }
148

149
1x
    for codeStr, localeMessages := range data {
150
2x
        code := ErrorCode(codeStr)
151
2x
        for localeStr, message := range localeMessages {
152
4x
            locale := Locale(localeStr)
153
4x
            lm.AddMessage(code, locale, message)
154
4x
        }
155
    }
156

157
1x
    return nil
158
}
159

160
// GetSupportedLocales returns a list of supported locales
161
1x
func (lm *LocalizedMessages) GetSupportedLocales() []Locale {
162
1x
    locales := make(map[Locale]bool)
163
1x

164
1x
    for _, localeMessages := range lm.messages {
165
2x
        for locale := range localeMessages {
166
3x
            locales[locale] = true
167
3x
        }
168
    }
169

170
1x
    result := make([]Locale, 0, len(locales))
171
1x
    for locale := range locales {
172
3x
        result = append(result, locale)
173
3x
    }
174

175
1x
    return result
176
}
177

178
// ParseLocale parses a locale string (e.g., "en-US", "fr-CA") and returns the language part
179
10x
func ParseLocale(localeStr string) Locale {
180
10x
    // Handle locale formats like "en-US", "fr-CA", etc.
181
10x
    parts := strings.Split(localeStr, "-")
182
10x
    if len(parts) > 0 && parts[0] != "" {
183
9x
        return Locale(strings.ToLower(parts[0]))
184
9x
    }
185
1x
    return LocaleEnglish // Default fallback
186
}
187

188
// Global instance of localized messages
189
var globalLocalizedMessages = NewLocalizedMessages()
190

191
// init loads default localized messages
192
1x
func init() {
193
1x
    // Load some basic localized messages
194
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInvalidInput, LocaleSpanish, "Entrada invÃlida")
195
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInvalidInput, LocaleFrench, "EntrÃe invalide")
196
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInvalidInput, LocaleGerman, "UngÃltige Eingabe")
197
1x

198
1x
    globalLocalizedMessages.AddMessage(ErrorCodeRecordNotFound, LocaleSpanish, "Registro no encontrado")
199
1x
    globalLocalizedMessages.AddMessage(ErrorCodeRecordNotFound, LocaleFrench, "Enregistrement non trouvÃ")
200
1x
    globalLocalizedMessages.AddMessage(ErrorCodeRecordNotFound, LocaleGerman, "Datensatz nicht gefunden")
201
1x

202
1x
    globalLocalizedMessages.AddMessage(ErrorCodeUnauthorized, LocaleSpanish, "Acceso no autorizado")
203
1x
    globalLocalizedMessages.AddMessage(ErrorCodeUnauthorized, LocaleFrench, "AccÃs non autorisÃ")
204
1x
    globalLocalizedMessages.AddMessage(ErrorCodeUnauthorized, LocaleGerman, "Unbefugter Zugriff")
205
1x

206
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInternalError, LocaleSpanish, "Error interno del servidor")
207
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInternalError, LocaleFrench, "Erreur interne du serveur")
208
1x
    globalLocalizedMessages.AddMessage(ErrorCodeInternalError, LocaleGerman, "Interner Serverfehler")
209
1x
}
210

211
// GetLocalizedMessage returns a localized error message using the global instance
212
5x
func GetLocalizedMessage(code ErrorCode, locale Locale) string {
213
5x
    return globalLocalizedMessages.GetMessage(code, locale)
214
5x
}
215

216
// GetLocalizedMessageWithDetails returns a localized error message with details
217
2x
func GetLocalizedMessageWithDetails(code ErrorCode, locale Locale, details string) string {
218
2x
    return globalLocalizedMessages.GetMessageWithDetails(code, locale, details)
219
2x
}
220

221
// SetGlobalLocalizedMessages sets the global localized messages instance
222
1x
func SetGlobalLocalizedMessages(messages *LocalizedMessages) {
223
1x
    globalLocalizedMessages = messages
224
1x
}
225


			
quizapp internal utils validation.go
100.0%
Statements
5/5
1
package contextutils
2

3
import (
4
    "strings"
5
)
6

7
// MaskAPIKey masks an API key for logging purposes to prevent exposure
8
// Returns a masked version that shows only first 4 and last 4 characters
9
17x
func MaskAPIKey(apiKey string) string {
10
17x
    if apiKey == "" {
11
1x
        return "[EMPTY]"
12
1x
    }
13

14
16x
    if len(apiKey) <= 8 {
15
3x
        return strings.Repeat("*", len(apiKey))
16
3x
    }
17

18
13x
    return apiKey[:4] + strings.Repeat("*", len(apiKey)-8) + apiKey[len(apiKey)-4:]
19
}
20


			
quizapp internal utils validation.go
54.7%
Statements
29/53
1
package contextutils
2

3
import (
4
    "context"
5
    "time"
6

7
    "quizapp/internal/models"
8
)
9

10
// ParseDateInUserTimezone parses a YYYY-MM-DD date string in the user's timezone.
11
// The userLookup function is injected to fetch the user (to avoid tight coupling and enable testing).
12
// Returns the parsed time (in the location), the effective timezone name (or "UTC" on fallback), and an error.
13
// If the date format is invalid, the returned error will be wrapped with the message "invalid date format".
14
func ParseDateInUserTimezone(
15
    ctx context.Context,
16
    userID int,
17
    dateStr string,
18
    userLookup func(context.Context, int) (*models.User, error),
19
1x
) (time.Time, string, error) {
20
1x
    user, err := userLookup(ctx, userID)
21
1x
    if err != nil {
22
        return time.Time{}, "", err
23
    }
24

25
1x
    timezone := "UTC"
26
1x
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
27
1x
        timezone = user.Timezone.String
28
1x
    }
29

30
1x
    loc, err := time.LoadLocation(timezone)
31
1x
    if err != nil {
32
        // Fallback to UTC if invalid timezone
33
        loc = time.UTC
34
        timezone = "UTC"
35
    }
36

37
1x
    date, err := time.ParseInLocation("2006-01-02", dateStr, loc)
38
1x
    if err != nil {
39
        return time.Time{}, timezone, WrapError(err, "invalid date format")
40
    }
41

42
1x
    return date, timezone, nil
43
}
44

45
// ConvertTimeToUserLocation converts the provided time to the user's timezone.
46
// Returns the converted time and the effective timezone name (or "UTC" on fallback).
47
func ConvertTimeToUserLocation(
48
    ctx context.Context,
49
    userID int,
50
    t time.Time,
51
    userLookup func(context.Context, int) (*models.User, error),
52
) (time.Time, string, error) {
53
    user, err := userLookup(ctx, userID)
54
    if err != nil {
55
        return time.Time{}, "", err
56
    }
57

58
    timezone := "UTC"
59
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
60
        timezone = user.Timezone.String
61
    }
62

63
    loc, err := time.LoadLocation(timezone)
64
    if err != nil {
65
        loc = time.UTC
66
        timezone = "UTC"
67
    }
68

69
    return t.In(loc), timezone, nil
70
}
71

72
// FormatTimeInUserTimezone formats the provided time in the user's timezone using the given layout.
73
// Returns the formatted string and the effective timezone name.
74
func FormatTimeInUserTimezone(
75
    ctx context.Context,
76
    userID int,
77
    t time.Time,
78
    layout string,
79
    userLookup func(context.Context, int) (*models.User, error),
80
1x
) (string, string, error) {
81
1x
    // If the stored timestamp is exactly midnight UTC with zero nanoseconds,
82
1x
    // it may be a date-only value (missing timezone). We only treat it as
83
1x
    // missing if the user has a configured timezone that is not UTC.
84
1x
    if t.Location() == time.UTC && t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 && t.Nanosecond() == 0 {
85
1x
        if userLookup != nil {
86
1x
            if u, err := userLookup(ctx, userID); err == nil && u != nil && u.Timezone.Valid && u.Timezone.String != "" && u.Timezone.String != "UTC" {
87
1x
                return "", "", ErrTimestampMissingTimezone
88
1x
            }
89
        }
90
    }
91

92
    tt, tz, err := ConvertTimeToUserLocation(ctx, userID, t, userLookup)
93
    if err != nil {
94
        return "", tz, err
95
    }
96
    res := tt.Format(layout)
97
    return res, tz, nil
98
}
99

100
// UserLocalDayRange returns the UTC start and end timestamps that cover the
101
// last `days` calendar days for the given user in their configured timezone.
102
// The range is [startUTC, endUTC) where startUTC is the start of the earliest
103
// local day at 00:00 and endUTC is the start of the day after "today" at 00:00
104
// in UTC. The userLookup function is used to fetch the user's timezone.
105
2x
func UserLocalDayRange(ctx context.Context, userID, days int, userLookup func(context.Context, int) (*models.User, error)) (time.Time, time.Time, string, error) {
106
2x
    if days <= 0 {
107
        days = 1
108
    }
109
2x
    user, err := userLookup(ctx, userID)
110
2x
    if err != nil {
111
        return time.Time{}, time.Time{}, "", err
112
    }
113

114
2x
    timezone := "UTC"
115
2x
    if user != nil && user.Timezone.Valid && user.Timezone.String != "" {
116
1x
        timezone = user.Timezone.String
117
1x
    }
118

119
2x
    loc, err := time.LoadLocation(timezone)
120
2x
    if err != nil {
121
        loc = time.UTC
122
        timezone = "UTC"
123
    }
124

125
2x
    now := time.Now().In(loc)
126
2x
    today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
127
2x
    startLocal := today.AddDate(0, 0, -(days - 1))
128
2x
    // start of the day after today
129
2x
    endLocal := today.Add(24 * time.Hour)
130
2x

131
2x
    startUTC := startLocal.UTC()
132
2x
    endUTC := endLocal.UTC()
133
2x
    return startUTC, endUTC, timezone, nil
134
}
135


			
quizapp internal utils validation.go
100.0%
Statements
1/1
1
package contextutils
2

3
import (
4
    "github.com/go-playground/validator/v10"
5
)
6

7
var validate = validator.New()
8

9
// IsValidEmail checks if an email address is valid using go-playground/validator
10
14x
func IsValidEmail(email string) bool {
11
14x
    return validate.Var(email, "email") == nil
12
14x
}
13


			
quizapp internal worker
76.8%
Statements
560/729
worker.go
76.8%
560/729
quizapp internal worker worker.go
76.8%
Statements
560/729
1
// Package worker contains the background worker responsible for generating
2
// and maintaining daily question assignments, scheduling generation jobs,
3
// and reporting worker health. The worker runs independently of HTTP
4
// request handling and interacts with the database, AI providers, and
5
// other internal services to keep question queues primed for users.
6
package worker
7

8
import (
9
    "context"
10
    "database/sql"
11
    "encoding/json"
12
    "fmt"
13
    "math"
14
    "os"
15
    "strconv"
16
    "strings"
17
    "sync"
18
    "time"
19

20
    "quizapp/internal/config"
21
    "quizapp/internal/models"
22
    "quizapp/internal/observability"
23
    "quizapp/internal/services"
24
    "quizapp/internal/services/mailer"
25
    contextutils "quizapp/internal/utils"
26

27
    "go.opentelemetry.io/otel"
28
    "go.opentelemetry.io/otel/attribute"
29
    "go.opentelemetry.io/otel/trace"
30
)
31

32
const (
33
    // NoActionPrefix is used to identify actions that should not be processed
34
    NoActionPrefix        = config.NoActionPrefix
35
    triggerThrottleWindow = config.WorkerTriggerThrottle // Prevent multiple triggers for same user within this window
36
)
37

38
// Status represents the current state of the worker
39
type Status struct {
40
    IsRunning       bool      `json:"is_running"`
41
    IsPaused        bool      `json:"is_paused"`
42
    CurrentActivity string    `json:"current_activity,omitempty"`
43
    LastRunStart    time.Time `json:"last_run_start"`
44
    LastRunFinish   time.Time `json:"last_run_finish"`
45
    LastRunError    string    `json:"last_run_error,omitempty"`
46
    NextRun         time.Time `json:"next_run"`
47
}
48

49
// RunRecord tracks individual worker runs
50
type RunRecord struct {
51
    StartTime time.Time     `json:"start_time"`
52
    EndTime   time.Time     `json:"end_time"`
53
    Duration  time.Duration `json:"duration"`
54
    Status    string        `json:"status"` // Success, Failure
55
    Details   string        `json:"details"`
56
}
57

58
// ActivityLog represents a single activity log entry
59
type ActivityLog struct {
60
    Timestamp time.Time `json:"timestamp"`
61
    Level     string    `json:"level"` // INFO, WARN, ERROR
62
    Message   string    `json:"message"`
63
    UserID    *int      `json:"user_id,omitempty"`
64
    Username  *string   `json:"username,omitempty"`
65
}
66

67
// UserFailureInfo tracks failure information for exponential backoff
68
type UserFailureInfo struct {
69
    ConsecutiveFailures int
70
    LastFailureTime     time.Time
71
    NextRetryTime       time.Time
72
}
73

74
// Config holds worker-specific configuration
75
type Config struct {
76
    StartWorkerPaused bool
77
    DailyHorizonDays  int
78
}
79

80
// Worker manages AI question generation in the background
81
type Worker struct {
82
    userService          services.UserServiceInterface
83
    questionService      services.QuestionServiceInterface
84
    aiService            services.AIServiceInterface
85
    learningService      services.LearningServiceInterface
86
    workerService        services.WorkerServiceInterface
87
    dailyQuestionService services.DailyQuestionServiceInterface
88
    emailService         mailer.Mailer
89
    hintService          services.GenerationHintServiceInterface
90
    instance             string
91
    status               Status
92
    history              []RunRecord
93
    activityLogs         []ActivityLog // Circular buffer for recent activity logs
94
    mu                   sync.RWMutex
95
    manualTrigger        chan bool
96
    cfg                  *config.Config
97
    workerCfg            Config
98
    logger               *observability.Logger
99

100
    // Track failures for exponential backoff
101
    userFailures map[int]*UserFailureInfo // userID -> failure info
102
    failureMu    sync.RWMutex             // mutex for failure tracking
103

104
    // Time function for testing - defaults to time.Now
105
    timeNow func() time.Time
106
    cancel  context.CancelFunc // Added for cleanup
107
}
108

109
// checkForDailyReminders checks if any users need daily reminder emails
110
11x
func (w *Worker) checkForDailyReminders(ctx context.Context) error {
111
11x
    ctx, span := otel.Tracer("worker").Start(ctx, "checkForDailyReminders",
112
11x
        trace.WithAttributes(
113
11x
            attribute.String("worker.instance", w.instance),
114
11x
            attribute.Bool("email.daily_reminder.enabled", w.cfg.Email.DailyReminder.Enabled),
115
11x
            attribute.Int("email.daily_reminder.hour", w.cfg.Email.DailyReminder.Hour),
116
11x
            attribute.Bool("email.enabled", w.cfg.Email.Enabled),
117
11x
        ),
118
11x
    )
119
11x
    defer span.End()
120
11x

121
11x
    if !w.cfg.Email.DailyReminder.Enabled {
122
1x
        w.logger.Info(ctx, "Daily reminders disabled, skipping", nil)
123
1x
        return nil
124
1x
    }
125

126
    // Get current time in UTC
127
9x
    now := w.timeNow().UTC()
128
9x
    currentHour := now.Hour()
129
9x

130
9x
    // Check if it's time to send reminders (default: 9 AM)
131
9x
    reminderHour := w.cfg.Email.DailyReminder.Hour
132
9x
    if currentHour != reminderHour {
133
5x
        span.SetAttributes(
134
5x
            attribute.Int("check.current_hour", currentHour),
135
5x
            attribute.Int("check.reminder_hour", reminderHour),
136
5x
            attribute.Bool("check.should_send", false),
137
5x
            attribute.String("check.reason", "wrong_hour"),
138
5x
        )
139
5x
        return nil
140
5x
    }
141

142
2x
    span.SetAttributes(
143
2x
        attribute.Int("check.current_hour", currentHour),
144
2x
        attribute.Int("check.reminder_hour", reminderHour),
145
2x
        attribute.Bool("check.should_send", true),
146
2x
    )
147
2x

148
2x
    w.logger.Info(ctx, "Checking for users needing daily reminders", map[string]interface{}{
149
2x
        "reminder_hour": reminderHour,
150
2x
    })
151
2x

152
2x
    // Get users who need daily reminders
153
2x
    users, err := w.getUsersNeedingDailyReminders(ctx)
154
2x
    if err != nil {
155
        span.RecordError(err)
156
        span.SetAttributes(
157
            attribute.Int("users.total", 0),
158
            attribute.Int("users.eligible", 0),
159
            attribute.Int("reminders.sent", 0),
160
        )
161
        w.logger.Error(ctx, "Failed to get users needing daily reminders", err, nil)
162
        return contextutils.WrapError(err, "failed to get users needing daily reminders")
163
    }
164

165
2x
    span.SetAttributes(
166
2x
        attribute.Int("users.total", len(users)),
167
2x
    )
168
2x

169
2x
    remindersSent := 0
170
2x
    failedReminders := 0
171
2x

172
2x
    for _, user := range users {
173
1x
        // Record the sent notification
174
1x
        subject := "Time for your daily quiz! ð"
175
1x
        status := "sent"
176
1x
        errorMsg := ""
177
1x

178
1x
        if err := w.emailService.SendDailyReminder(ctx, &user); err != nil {
179
            failedReminders++
180
            status = "failed"
181
            errorMsg = err.Error()
182
            w.logger.Error(ctx, "Failed to send daily reminder", err, map[string]interface{}{
183
                "user_id": user.ID,
184
                "email":   user.Email.String,
185
            })
186
        } else {
187
1x
            remindersSent++
188
1x
        }
189

190
        // Record the sent notification in the database
191
1x
        if err := w.emailService.RecordSentNotification(ctx, user.ID, "daily_reminder", subject, "daily_reminder", status, errorMsg); err != nil {
192
            w.logger.Error(ctx, "Failed to record sent notification", err, map[string]interface{}{
193
                "user_id": user.ID,
194
            })
195
        }
196

197
        // Update the last reminder sent timestamp for this user
198
1x
        if err := w.learningService.UpdateLastDailyReminderSent(ctx, user.ID); err != nil {
199
            w.logger.Error(ctx, "Failed to update last daily reminder sent timestamp", err, map[string]interface{}{
200
                "user_id": user.ID,
201
            })
202
            // Don't count this as a failed reminder since the email was sent successfully
203
        }
204
    }
205

206
2x
    span.SetAttributes(
207
2x
        attribute.Int("users.eligible", len(users)),
208
2x
        attribute.Int("reminders.sent", remindersSent),
209
2x
        attribute.Int("reminders.failed", failedReminders),
210
2x
        attribute.Float64("reminders.success_rate", float64(remindersSent)/float64(len(users))),
211
2x
    )
212
2x

213
2x
    w.logger.Info(ctx, "Daily reminders processed", map[string]interface{}{
214
2x
        "total_users":    len(users),
215
2x
        "reminders_sent": remindersSent,
216
2x
        "reminder_hour":  reminderHour,
217
2x
    })
218
2x

219
2x
    return nil
220
}
221

222
// getUsersNeedingDailyReminders returns users who should receive daily reminders
223
3x
func (w *Worker) getUsersNeedingDailyReminders(ctx context.Context) ([]models.User, error) {
224
3x
    ctx, span := otel.Tracer("worker").Start(ctx, "getUsersNeedingDailyReminders")
225
3x
    defer span.End()
226
3x

227
3x
    // Get all users and filter for those with email addresses and daily reminders enabled
228
3x
    users, err := w.userService.GetAllUsers(ctx)
229
3x
    if err != nil {
230
        span.RecordError(err)
231
        return nil, contextutils.WrapError(err, "failed to get users")
232
    }
233

234
3x
    var eligibleUsers []models.User
235
3x
    today := w.timeNow().UTC().Format("2006-01-02")
236
3x

237
3x
    for _, user := range users {
238
9x
        // Check if user has email address
239
9x
        if !user.Email.Valid || user.Email.String == "" {
240
2x
            continue
241
        }
242

243
        // Get user's learning preferences to check daily reminder setting
244
7x
        prefs, err := w.learningService.GetUserLearningPreferences(ctx, user.ID)
245
7x
        if err != nil {
246
            w.logger.Warn(ctx, "Failed to get user learning preferences for daily reminder check", map[string]interface{}{
247
                "user_id":  user.ID,
248
                "username": user.Username,
249
                "error":    err.Error(),
250
            })
251
            continue
252
        }
253

254
        // Check if daily reminders are enabled for this user
255
7x
        if prefs == nil || !prefs.DailyReminderEnabled {
256
3x
            continue
257
        }
258

259
        // Check if we've already sent a reminder today
260
4x
        if prefs.LastDailyReminderSent != nil {
261
2x
            lastReminderDate := prefs.LastDailyReminderSent.Format("2006-01-02")
262
2x
            if lastReminderDate == today {
263
2x
                continue
264
            }
265
        }
266

267
2x
        eligibleUsers = append(eligibleUsers, user)
268
    }
269

270
3x
    w.logger.Info(ctx, "Found users eligible for daily reminders", map[string]interface{}{
271
3x
        "total_users":    len(users),
272
3x
        "eligible_users": len(eligibleUsers),
273
3x
    })
274
3x

275
3x
    return eligibleUsers, nil
276
}
277

278
// checkForDailyQuestionAssignments assigns daily questions to all eligible users
279
// This runs independently of email reminders to ensure users get daily questions
280
// even if they have email reminders disabled
281
17x
func (w *Worker) checkForDailyQuestionAssignments(ctx context.Context) error {
282
17x
    ctx, span := observability.TraceWorkerFunction(ctx, "check_for_daily_question_assignments",
283
17x
        attribute.String("worker.instance", w.instance),
284
17x
    )
285
17x
    defer observability.FinishSpan(span, nil)
286
17x

287
17x
    w.logger.Info(ctx, "Checking for daily question assignments", map[string]interface{}{
288
17x
        "instance": w.instance,
289
17x
    })
290
17x

291
17x
    // Get users who are eligible for daily questions
292
17x
    users, err := w.getUsersEligibleForDailyQuestions(ctx)
293
17x
    if err != nil {
294
        span.RecordError(err)
295
        w.logger.Error(ctx, "Failed to get users eligible for daily questions", err, nil)
296
        return contextutils.WrapError(err, "failed to get users eligible for daily questions")
297
    }
298

299
17x
    if len(users) == 0 {
300
4x
        w.logger.Info(ctx, "No users eligible for daily question assignments", map[string]interface{}{
301
4x
            "instance": w.instance,
302
4x
        })
303
4x
        return nil
304
4x
    }
305

306
13x
    span.SetAttributes(
307
13x
        attribute.Int("users.total", len(users)),
308
13x
    )
309
13x

310
13x
    successfulAssignments := 0
311
13x
    failedAssignments := 0
312
13x

313
13x
    for _, user := range users {
314
17x
        // Get user's timezone, default to UTC if not set
315
17x
        timezone := "UTC"
316
17x
        if user.Timezone.Valid && user.Timezone.String != "" {
317
3x
            timezone = user.Timezone.String
318
3x
        }
319

320
        // Get today's date in the user's timezone
321
17x
        loc, err := time.LoadLocation(timezone)
322
17x
        if err != nil {
323
            w.logger.Warn(ctx, "Invalid timezone for user, using UTC", map[string]interface{}{
324
                "user_id":  user.ID,
325
                "username": user.Username,
326
                "timezone": timezone,
327
                "error":    err.Error(),
328
            })
329
            loc = time.UTC
330
        }
331

332
        // Get today's date in the user's timezone
333
17x
        now := w.timeNow().In(loc)
334
17x
        today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
335
17x

336
17x
        // Assign daily questions for dates in [today .. today+N]
337
17x
        horizon := w.workerCfg.DailyHorizonDays
338
17x
        if horizon <= 0 {
339
            // default to 2 days ahead when misconfigured or not set
340
            horizon = 2
341
        }
342

343
        // Ensure the worker horizon covers the configured avoid window so
344
        // that when future assignments are removed (e.g., after a correct
345
        // submission) the worker run will top up missing slots. Use server
346
        // config as the source of truth for the avoid window.
347
17x
        avoidDays := 7
348
17x
        if w.cfg != nil && w.cfg.Server.DailyRepeatAvoidDays > 0 {
349
3x
            avoidDays = w.cfg.Server.DailyRepeatAvoidDays
350
3x
        }
351
17x
        if horizon < avoidDays {
352
17x
            w.logger.Info(ctx, "Extending worker daily horizon to cover daily repeat avoid window", map[string]interface{}{
353
17x
                "old_horizon": horizon,
354
17x
                "new_horizon": avoidDays,
355
17x
                "user_id":     user.ID,
356
17x
            })
357
17x
            horizon = avoidDays
358
17x
        }
359
17x
        for d := 0; d <= horizon; d++ {
360
136x
            target := today.AddDate(0, 0, d)
361
136x
            // Assign daily questions for target date in user's timezone
362
136x
            if err := w.dailyQuestionService.AssignDailyQuestions(ctx, user.ID, target); err != nil {
363
40x
                failedAssignments++
364
40x
                w.logger.Error(ctx, "Failed to assign daily questions", err, map[string]interface{}{
365
40x
                    "user_id":  user.ID,
366
40x
                    "username": user.Username,
367
40x
                    "timezone": timezone,
368
40x
                    "date":     target.Format("2006-01-02"),
369
40x
                })
370
40x
            } else {
371
96x
                successfulAssignments++
372
96x
                w.logger.Info(ctx, "Successfully assigned daily questions", map[string]interface{}{
373
96x
                    "user_id":  user.ID,
374
96x
                    "username": user.Username,
375
96x
                    "timezone": timezone,
376
96x
                    "date":     target.Format("2006-01-02"),
377
96x
                })
378
96x
            }
379
        }
380
    }
381

382
13x
    span.SetAttributes(
383
13x
        attribute.Int("assignments.successful", successfulAssignments),
384
13x
        attribute.Int("assignments.failed", failedAssignments),
385
13x
    )
386
13x

387
13x
    w.logger.Info(ctx, "Completed daily question assignment check", map[string]interface{}{
388
13x
        "instance":               w.instance,
389
13x
        "eligible_users":         len(users),
390
13x
        "successful_assignments": successfulAssignments,
391
13x
        "failed_assignments":     failedAssignments,
392
13x
    })
393
13x

394
13x
    return nil
395
}
396

397
// getUsersEligibleForDailyQuestions returns users who should receive daily questions
398
// This is independent of email reminder preferences
399
25x
func (w *Worker) getUsersEligibleForDailyQuestions(ctx context.Context) ([]models.User, error) {
400
25x
    ctx, span := otel.Tracer("worker").Start(ctx, "getUsersEligibleForDailyQuestions")
401
25x
    defer span.End()
402
25x

403
25x
    // Get all users
404
25x
    users, err := w.userService.GetAllUsers(ctx)
405
25x
    if err != nil {
406
1x
        span.RecordError(err)
407
1x
        return nil, contextutils.WrapError(err, "failed to get users")
408
1x
    }
409

410
23x
    var eligibleUsers []models.User
411
23x

412
23x
    for _, user := range users {
413
35x
        // Check if user has language and level preferences set
414
35x
        if !user.PreferredLanguage.Valid || user.PreferredLanguage.String == "" {
415
3x
            w.logger.Debug(ctx, "User missing preferred language, skipping daily question assignment", map[string]interface{}{
416
3x
                "user_id":  user.ID,
417
3x
                "username": user.Username,
418
3x
            })
419
3x
            continue
420
        }
421

422
29x
        if !user.CurrentLevel.Valid || user.CurrentLevel.String == "" {
423
3x
            w.logger.Debug(ctx, "User missing current level, skipping daily question assignment", map[string]interface{}{
424
3x
                "user_id":  user.ID,
425
3x
                "username": user.Username,
426
3x
            })
427
3x
            continue
428
        }
429

430
23x
        eligibleUsers = append(eligibleUsers, user)
431
    }
432

433
23x
    w.logger.Info(ctx, "Found users eligible for daily questions", map[string]interface{}{
434
23x
        "total_users":    len(users),
435
23x
        "eligible_users": len(eligibleUsers),
436
23x
    })
437
23x

438
23x
    return eligibleUsers, nil
439
}
440

441
// NewWorker creates a new Worker instance
442
123x
func NewWorker(userService services.UserServiceInterface, questionService services.QuestionServiceInterface, aiService services.AIServiceInterface, learningService services.LearningServiceInterface, workerService services.WorkerServiceInterface, dailyQuestionService services.DailyQuestionServiceInterface, emailService mailer.Mailer, hintService services.GenerationHintServiceInterface, instance string, cfg *config.Config, logger *observability.Logger) *Worker {
443
123x
    if instance == "" {
444
1x
        instance = "default"
445
1x
    }
446

447
123x
    ctx, cancel := context.WithCancel(context.Background())
448
123x

449
123x
    // Prefer value from config file when set (>0). If not set, default to 1.
450
123x
    dailyHorizon := cfg.Server.DailyHorizonDays
451
123x
    if dailyHorizon <= 0 {
452
52x
        dailyHorizon = 1
453
52x
    }
454

455
123x
    w := &Worker{
456
123x
        userService:          userService,
457
123x
        questionService:      questionService,
458
123x
        aiService:            aiService,
459
123x
        learningService:      learningService,
460
123x
        workerService:        workerService,
461
123x
        dailyQuestionService: dailyQuestionService,
462
123x
        emailService:         emailService,
463
123x
        hintService:          hintService,
464
123x
        instance:             instance,
465
123x
        status:               Status{IsRunning: false, CurrentActivity: "Initialized"},
466
123x
        history:              make([]RunRecord, 0, cfg.Server.MaxHistory),
467
123x
        activityLogs:         make([]ActivityLog, 0, cfg.Server.MaxActivityLogs),
468
123x
        manualTrigger:        make(chan bool, 1),
469
123x
        cfg:                  cfg,
470
123x
        workerCfg:            Config{StartWorkerPaused: getEnvBool("WORKER_START_PAUSED", false), DailyHorizonDays: dailyHorizon},
471
123x
        logger:               logger,
472
123x
        userFailures:         make(map[int]*UserFailureInfo),
473
123x
        timeNow:              time.Now, // Default to real time
474
123x
    }
475
123x

476
123x
    // Handle startup pause if configured
477
123x
    if w.workerCfg.StartWorkerPaused {
478
        w.handleStartupPause(ctx)
479
    }
480

481
    // Store cancel function for cleanup
482
123x
    w.cancel = cancel
483
123x

484
123x
    return w
485
}
486

487
// getEnvBool is a helper function to get boolean environment variables
488
131x
func getEnvBool(key string, defaultValue bool) bool {
489
131x
    valStr := os.Getenv(key)
490
131x
    if valStr == "" {
491
125x
        return defaultValue
492
125x
    }
493
3x
    val, err := strconv.ParseBool(valStr)
494
3x
    if err != nil {
495
1x
        return defaultValue
496
1x
    }
497
2x
    return val
498
}
499

500
// Start begins the worker's background processing loop
501
1x
func (w *Worker) Start(ctx context.Context) {
502
1x
    w.status.IsRunning = true
503
1x
    w.updateDatabaseStatus(ctx)
504
1x

505
1x
    w.handleStartupPause(ctx)
506
1x

507
1x
    // Start heartbeat goroutine
508
1x
    go w.heartbeatLoop(ctx)
509
1x

510
1x
    // Main worker loop
511
1x
    ticker := time.NewTicker(config.WorkerHeartbeatInterval) // Check every 30 seconds
512
1x
    defer ticker.Stop()
513
1x

514
1x
    initialStatus := w.getInitialWorkerStatus(ctx)
515
1x

516
1x
    w.logger.Info(ctx, "Worker started", map[string]interface{}{
517
1x
        "instance": w.instance,
518
1x
        "status":   initialStatus,
519
1x
    })
520
1x
    w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s started (%s)", w.instance, initialStatus), nil, nil)
521
1x

522
1x
    for {
523
1x
        select {
524
1x
        case <-ctx.Done():
525
1x
            w.logger.Info(ctx, "Worker shutting down", map[string]interface{}{
526
1x
                "instance": w.instance,
527
1x
            })
528
1x
            w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s shutting down", w.instance), nil, nil)
529
1x
            w.status.IsRunning = false
530
1x
            w.updateDatabaseStatus(ctx)
531
1x
            return
532

533
        case <-ticker.C:
534
            w.run()
535

536
        case <-w.manualTrigger:
537
            w.logger.Info(ctx, "Worker triggered manually", map[string]interface{}{
538
                "instance": w.instance,
539
            })
540
            w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s triggered manually", w.instance), nil, nil)
541
            w.run()
542
        }
543
    }
544
}
545

546
// handleStartupPause sets global pause if configured
547
3x
func (w *Worker) handleStartupPause(ctx context.Context) {
548
3x
    if w.workerCfg.StartWorkerPaused {
549
1x
        w.logger.Info(ctx, "Worker configured to start paused - setting global pause", map[string]interface{}{
550
1x
            "instance": w.instance,
551
1x
        })
552
1x
        if err := w.workerService.SetGlobalPause(ctx, true); err != nil {
553
            w.logger.Error(ctx, "Failed to set global pause on startup", err, map[string]interface{}{
554
                "instance": w.instance,
555
            })
556
        } else {
557
1x
            w.logger.Info(ctx, "Global pause set on startup as configured", map[string]interface{}{
558
1x
                "instance": w.instance,
559
1x
            })
560
1x
        }
561
    }
562
}
563

564
// getInitialWorkerStatus determines the initial status string
565
3x
func (w *Worker) getInitialWorkerStatus(ctx context.Context) string {
566
3x
    initialStatus := "running"
567
3x
    globalPaused, err := w.workerService.IsGlobalPaused(ctx)
568
3x
    if err != nil {
569
        w.logger.Error(ctx, "Failed to check global pause status on startup", err, map[string]interface{}{
570
            "instance": w.instance,
571
        })
572
    } else if globalPaused {
573
        initialStatus = "paused (globally)"
574
    } else {
575
3x
        status, err := w.workerService.GetWorkerStatus(ctx, w.instance)
576
3x
        if err != nil {
577
            // Worker status not found is expected on first startup - this is normal
578
            w.logger.Debug(ctx, "Worker status not found on startup (expected for new worker)", map[string]interface{}{
579
                "instance": w.instance,
580
            })
581
        } else if status != nil && status.IsPaused {
582
            initialStatus = "paused (instance)"
583
1x
        }
584
    }
585
3x
    return initialStatus
586
}
587

588
2x
func (w *Worker) heartbeatLoop(ctx context.Context) {
589
2x
    ticker := time.NewTicker(config.WorkerHeartbeatInterval) // Heartbeat every 30 seconds
590
2x
    defer ticker.Stop()
591
2x

592
2x
    for {
593
2x
        select {
594
2x
        case <-ctx.Done():
595
2x
            return
596
        case <-ticker.C:
597
            w.updateHeartbeat(ctx)
598
        }
599
    }
600
}
601

602
// updateHeartbeat updates the heartbeat in the database
603
1x
func (w *Worker) updateHeartbeat(ctx context.Context) {
604
1x
    if err := w.workerService.UpdateHeartbeat(ctx, w.instance); err != nil {
605
        w.logger.Error(ctx, "Failed to update heartbeat for worker", err, map[string]interface{}{
606
            "instance": w.instance,
607
        })
608
    }
609
}
610

611
// run executes a single worker cycle
612
4x
func (w *Worker) run() {
613
4x
    ctx, span := observability.TraceWorkerFunction(context.Background(), "run",
614
4x
        attribute.String("worker.instance", w.instance),
615
4x
    )
616
4x
    defer observability.FinishSpan(span, nil)
617
4x

618
4x
    // Ensure worker status is up to date before checking pause status
619
4x
    w.updateDatabaseStatus(ctx)
620
4x

621
4x
    paused, reason := w.checkPauseStatus(ctx)
622
4x
    if paused {
623
1x
        span.SetAttributes(attribute.String("pause_reason", reason))
624
1x
        w.updateActivity(reason)
625
1x
        return
626
1x
    }
627

628
3x
    w.status.LastRunStart = time.Now()
629
3x
    w.updateDatabaseStatus(ctx)
630
3x
    details, err := w.generateNeededQuestions(ctx)
631
3x

632
3x
    // Assign daily questions to all eligible users (independent of email reminders)
633
3x
    if err := w.checkForDailyQuestionAssignments(ctx); err != nil {
634
        w.logger.Error(ctx, "Failed to check daily question assignments", err, map[string]interface{}{
635
            "instance": w.instance,
636
        })
637
    }
638

639
    // Check for daily email reminders
640
3x
    if err := w.checkForDailyReminders(ctx); err != nil {
641
        w.logger.Error(ctx, "Failed to check daily reminders", err, map[string]interface{}{
642
            "instance": w.instance,
643
        })
644
    }
645

646
3x
    w.status.LastRunFinish = time.Now()
647
3x
    if err != nil {
648
        w.status.LastRunError = err.Error()
649
        w.logger.Error(ctx, "Worker run failed", err, map[string]interface{}{
650
            "instance": w.instance,
651
        })
652
    } else {
653
3x
        w.status.LastRunError = ""
654
3x
    }
655

656
3x
    w.recordRunHistory(details, err)
657
3x
    w.updateDatabaseStatus(ctx)
658
}
659

660
// checkPauseStatus checks global and instance pause
661
6x
func (w *Worker) checkPauseStatus(ctx context.Context) (bool, string) {
662
6x
    globalPaused, err := w.workerService.IsGlobalPaused(ctx)
663
6x
    if err != nil {
664
        w.logger.Error(ctx, "Failed to check global pause status", err, map[string]interface{}{
665
            "instance": w.instance,
666
        })
667
        return true, "Error checking global pause status"
668
    }
669
6x
    if globalPaused {
670
3x
        return true, "Globally paused"
671
3x
    }
672
3x
    status, err := w.workerService.GetWorkerStatus(ctx, w.instance)
673
3x
    if err != nil {
674
        // Worker status not found might happen during startup - assume not paused
675
        w.logger.Debug(ctx, "Worker status not found during pause check (assuming not paused)", map[string]interface{}{
676
            "instance": w.instance,
677
        })
678
        return false, ""
679
    } else if status != nil && status.IsPaused {
680
        return true, "Worker instance paused"
681
    }
682
3x
    return false, ""
683
}
684

685
// recordRunHistory records the run in history and trims the slice
686
113x
func (w *Worker) recordRunHistory(details string, err error) {
687
113x
    record := RunRecord{
688
113x
        StartTime: w.status.LastRunStart,
689
113x
        EndTime:   w.status.LastRunFinish,
690
113x
        Duration:  w.status.LastRunFinish.Sub(w.status.LastRunStart),
691
113x
        Details:   details,
692
113x
    }
693
113x
    if err != nil {
694
        record.Status = "Failure"
695
    } else {
696
113x
        record.Status = "Success"
697
113x
    }
698
113x
    w.mu.Lock()
699
113x
    w.history = append(w.history, record)
700
113x
    if len(w.history) > w.cfg.Server.MaxHistory {
701
5x
        w.history = w.history[len(w.history)-w.cfg.Server.MaxHistory:]
702
5x
    }
703
113x
    w.mu.Unlock()
704
}
705

706
// GetStatus returns the current worker status
707
5x
func (w *Worker) GetStatus() Status {
708
5x
    w.mu.RLock()
709
5x
    defer w.mu.RUnlock()
710
5x
    return w.status
711
5x
}
712

713
// GetHistory returns the worker's run history
714
8x
func (w *Worker) GetHistory() []RunRecord {
715
8x
    w.mu.RLock()
716
8x
    defer w.mu.RUnlock()
717
8x
    // Return a copy to avoid race conditions
718
8x
    history := make([]RunRecord, len(w.history))
719
8x
    copy(history, w.history)
720
8x
    return history
721
8x
}
722

723
// GetActivityLogs returns recent activity logs
724
7x
func (w *Worker) GetActivityLogs() []ActivityLog {
725
7x
    w.mu.RLock()
726
7x
    defer w.mu.RUnlock()
727
7x

728
7x
    // Return a copy to avoid concurrent access issues
729
7x
    logs := make([]ActivityLog, len(w.activityLogs))
730
7x
    copy(logs, w.activityLogs)
731
7x
    return logs
732
7x
}
733

734
// GetInstance returns the worker instance name
735
1x
func (w *Worker) GetInstance() string {
736
1x
    return w.instance
737
1x
}
738

739
// GetEmailService returns the email service
740
func (w *Worker) GetEmailService() mailer.Mailer {
741
    return w.emailService
742
}
743

744
// TriggerManualRun triggers a manual worker run
745
5x
func (w *Worker) TriggerManualRun() {
746
5x
    ctx := context.Background()
747
5x
    select {
748
3x
    case w.manualTrigger <- true:
749
3x
        w.logger.Info(ctx, "Manual trigger sent to worker", map[string]interface{}{
750
3x
            "instance": w.instance,
751
3x
        })
752
1x
    default:
753
1x
        w.logger.Info(ctx, "Manual trigger already pending for worker", map[string]interface{}{
754
1x
            "instance": w.instance,
755
1x
        })
756
    }
757
}
758

759
// Pause pauses the worker
760
2x
func (w *Worker) Pause(ctx context.Context) {
761
2x
    if err := w.workerService.PauseWorker(ctx, w.instance); err != nil {
762
1x
        w.logger.Warn(ctx, "Failed to pause worker in service", map[string]interface{}{
763
1x
            "instance": w.instance,
764
1x
            "error":    err.Error(),
765
1x
        })
766
1x
    }
767
2x
    w.logger.Info(ctx, "Worker paused", map[string]interface{}{
768
2x
        "instance": w.instance,
769
2x
    })
770
2x
    w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s paused", w.instance), nil, nil)
771
2x
    w.status.IsPaused = true
772
2x
    w.updateDatabaseStatus(ctx)
773
}
774

775
// Resume resumes the worker
776
2x
func (w *Worker) Resume(ctx context.Context) {
777
2x
    if err := w.workerService.ResumeWorker(ctx, w.instance); err != nil {
778
1x
        w.logger.Warn(ctx, "Failed to resume worker in service", map[string]interface{}{
779
1x
            "instance": w.instance,
780
1x
            "error":    err.Error(),
781
1x
        })
782
1x
        // Do not unpause if resume failed
783
1x
        w.updateDatabaseStatus(ctx)
784
1x
        return
785
1x
    }
786
1x
    w.logger.Info(ctx, "Worker resumed", map[string]interface{}{
787
1x
        "instance": w.instance,
788
1x
    })
789
1x
    w.logActivity(ctx, "INFO", fmt.Sprintf("Worker %s resumed", w.instance), nil, nil)
790
1x
    w.status.IsPaused = false
791
1x
    w.updateDatabaseStatus(ctx)
792
}
793

794
// Shutdown gracefully shuts down the worker and cleans up resources
795
1x
func (w *Worker) Shutdown(ctx context.Context) error {
796
1x
    w.mu.Lock()
797
1x
    defer w.mu.Unlock()
798
1x

799
1x
    w.logger.Info(ctx, "Worker starting shutdown", map[string]interface{}{
800
1x
        "instance": w.instance,
801
1x
    })
802
1x

803
1x
    // Cancel the shutdown context to signal shutdown
804
1x
    if w.cancel != nil {
805
1x
        w.cancel()
806
1x
    }
807

808
    // Wait for any active operations to complete
809
    // This is a simple implementation - in a more complex system,
810
    // you might want to track active operations more precisely
811
1x
    time.Sleep(config.WorkerSleepDuration)
812
1x

813
1x
    // Clean up user failures map
814
1x
    w.failureMu.Lock()
815
1x
    w.userFailures = make(map[int]*UserFailureInfo)
816
1x
    w.failureMu.Unlock()
817
1x

818
1x
    // Clear activity logs
819
1x
    w.activityLogs = make([]ActivityLog, 0)
820
1x

821
1x
    w.logger.Info(ctx, "Worker shutdown completed", map[string]interface{}{
822
1x
        "instance": w.instance,
823
1x
    })
824
1x
    return nil
825
}
826

827
// updateDatabaseStatus updates the worker status in the database
828
20x
func (w *Worker) updateDatabaseStatus(ctx context.Context) {
829
20x
    dbStatus := &models.WorkerStatus{
830
20x
        WorkerInstance:          w.instance,
831
20x
        IsRunning:               w.status.IsRunning,
832
20x
        IsPaused:                w.status.IsPaused,
833
20x
        CurrentActivity:         sql.NullString{String: w.status.CurrentActivity, Valid: w.status.CurrentActivity != ""},
834
20x
        LastHeartbeat:           sql.NullTime{Time: time.Now(), Valid: true},
835
20x
        LastRunStart:            sql.NullTime{Time: w.status.LastRunStart, Valid: !w.status.LastRunStart.IsZero()},
836
20x
        LastRunFinish:           sql.NullTime{Time: w.status.LastRunFinish, Valid: !w.status.LastRunFinish.IsZero()},
837
20x
        LastRunError:            sql.NullString{String: w.status.LastRunError, Valid: w.status.LastRunError != ""},
838
20x
        TotalQuestionsGenerated: w.getTotalQuestionsGenerated(),
839
20x
        TotalRuns:               len(w.history),
840
20x
    }
841
20x

842
20x
    if err := w.workerService.UpdateWorkerStatus(ctx, w.instance, dbStatus); err != nil {
843
1x
        w.logger.Error(ctx, "Failed to update worker status in database", err, map[string]interface{}{
844
1x
            "instance": w.instance,
845
1x
        })
846
1x
    }
847
}
848

849
// getTotalQuestionsGenerated calculates total questions generated from run history
850
20x
func (w *Worker) getTotalQuestionsGenerated() int {
851
20x
    total := 0
852
20x
    for _, record := range w.history {
853
3x
        if record.Status == "Success" {
854
3x
            // Parse details to count questions - simplified for now
855
3x
            total++ // This would need to be enhanced to parse actual count
856
3x
        }
857
    }
858
20x
    return total
859
}
860

861
3x
func (w *Worker) generateNeededQuestions(ctx context.Context) (result0 string, err error) {
862
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "generate_needed_questions",
863
3x
        attribute.String("worker.instance", w.instance),
864
3x
    )
865
3x
    defer observability.FinishSpan(span, &err)
866
3x

867
3x
    // Check if globally paused BEFORE any work or logging
868
3x
    globalPaused, err := w.workerService.IsGlobalPaused(ctx)
869
3x
    if err != nil {
870
        span.RecordError(err)
871
        w.logger.Error(ctx, "Failed to check global pause status", err, map[string]interface{}{
872
            "instance": w.instance,
873
        })
874
        return "Error checking global pause status", err
875
    }
876
3x
    if globalPaused {
877
        span.SetAttributes(attribute.Bool("globally_paused", true))
878
        w.logger.Info(ctx, "Worker skipping question generation (globally paused)", map[string]interface{}{
879
            "instance": w.instance,
880
        })
881
        return "Run paused globally", nil
882
    }
883

884
3x
    aiUsers, err := w.getEligibleAIUsers(ctx)
885
3x
    if err != nil {
886
        return "Error getting users", err
887
    }
888
3x
    if len(aiUsers) == 0 {
889
3x
        w.logger.Info(ctx, "Worker: No active users with AI provider configuration found for question generation", map[string]interface{}{
890
3x
            "instance": w.instance,
891
3x
        })
892
3x
        return "No active users with AI provider configuration found", nil
893
3x
    }
894

895
    var actions []string
896
    var checkedUsers []string
897
    var actuallyProcessedUsers []string
898
    var hadAttemptedOperations bool
899
    var hadFailures bool
900

901
    for _, user := range aiUsers {
902
        checkedUsers = append(checkedUsers, user.Username)
903
        shouldProcess, skipReason := w.shouldProcessUser(ctx, &user)
904
        if !shouldProcess {
905
            if skipReason != "" {
906
                w.logger.Info(ctx, "Worker user check", map[string]interface{}{
907
                    "instance": w.instance,
908
                    "username": user.Username,
909
                    "reason":   skipReason,
910
                })
911
            }
912
            continue
913
        }
914
        actuallyProcessedUsers = append(actuallyProcessedUsers, user.Username)
915
        userActions, attempted, failed := w.processUserQuestionGeneration(ctx, &user)
916
        if attempted {
917
            hadAttemptedOperations = true
918
        }
919
        if failed {
920
            hadFailures = true
921
        }
922
        if userActions != "" {
923
            actions = append(actions, userActions)
924
        }
925
        w.logger.Info(ctx, "Worker completed check for user", map[string]interface{}{
926
            "instance": w.instance,
927
            "username": user.Username,
928
        })
929
    }
930

931
    w.updateActivity("")
932
    return w.summarizeRunActions(actions, checkedUsers, actuallyProcessedUsers, hadAttemptedOperations, hadFailures), nil
933
}
934

935
// getEligibleAIUsers returns users eligible for AI question generation
936
5x
func (w *Worker) getEligibleAIUsers(ctx context.Context) (result0 []models.User, err error) {
937
5x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_eligible_ai_users",
938
5x
        attribute.String("worker.instance", w.instance),
939
5x
    )
940
5x
    defer observability.FinishSpan(span, &err)
941
5x

942
5x
    users, err := w.userService.GetAllUsers(ctx)
943
5x
    if err != nil {
944
        span.RecordError(err)
945
        return nil, err
946
    }
947
5x
    var aiUsers []models.User
948
5x
    for _, user := range users {
949
7x
        if !user.AIEnabled.Valid || !user.AIEnabled.Bool {
950
3x
            continue
951
        }
952
2x
        userPaused, err := w.workerService.IsUserPaused(ctx, user.ID)
953
2x
        if err == nil && userPaused {
954
1x
            continue
955
        }
956
1x
        hasAIProvider := user.AIProvider.Valid && user.AIProvider.String != ""
957
1x
        hasAPIKey := false
958
1x
        if hasAIProvider {
959
1x
            savedKey, err := w.userService.GetUserAPIKey(ctx, user.ID, user.AIProvider.String)
960
1x
            if err == nil && savedKey != "" {
961
1x
                hasAPIKey = true
962
1x
            }
963
        }
964
1x
        if hasAPIKey || hasAIProvider {
965
1x
            aiUsers = append(aiUsers, user)
966
1x
        }
967
    }
968
5x
    return aiUsers, nil
969
}
970

971
// shouldProcessUser encapsulates exponential backoff and pause checks
972
4x
func (w *Worker) shouldProcessUser(ctx context.Context, user *models.User) (bool, string) {
973
4x
    if !w.shouldRetryUser(user.ID) {
974
1x
        w.failureMu.RLock()
975
1x
        failure := w.userFailures[user.ID]
976
1x
        nextRetry := time.Until(failure.NextRetryTime)
977
1x
        w.failureMu.RUnlock()
978
1x
        return false, fmt.Sprintf("Skipping due to exponential backoff (failure #%d, retry in %v)", failure.ConsecutiveFailures, nextRetry.Round(time.Second))
979
1x
    }
980
3x
    globalPaused, err := w.workerService.IsGlobalPaused(ctx)
981
3x
    if err != nil {
982
        return false, "Error checking global pause status"
983
    }
984
3x
    if globalPaused {
985
1x
        return false, "Run paused globally"
986
1x
    }
987
2x
    status, err := w.workerService.GetWorkerStatus(ctx, w.instance)
988
2x
    if err == nil && status != nil && status.IsPaused {
989
1x
        return false, fmt.Sprintf("Worker instance %s paused", w.instance)
990
1x
    }
991
1x
    if ctx.Err() != nil {
992
1x
        return false, "Shutdown initiated"
993
1x
    }
994
    return true, ""
995
}
996

997
// Helper: get the count of eligible questions for a user (excludes questions answered correctly in the last 2 days)
998
14x
func (w *Worker) getEligibleQuestionCount(ctx context.Context, userID int, language, level string, qType models.QuestionType) (result0 int, err error) {
999
14x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_eligible_question_count",
1000
14x
        observability.AttributeUserID(userID),
1001
14x
        attribute.String("language", language),
1002
14x
        attribute.String("level", level),
1003
14x
        attribute.String("question.type", string(qType)),
1004
14x
        attribute.String("worker.instance", w.instance),
1005
14x
    )
1006
14x
    defer observability.FinishSpan(span, &err)
1007
14x

1008
14x
    // Safe user lookup: tests may not wire userService
1009
14x
    userLookup := func(ctx context.Context, id int) (*models.User, error) {
1010
14x
        // Only use the concrete UserService implementation to avoid invoking mocks in unit tests
1011
14x
        if us, ok := w.userService.(*services.UserService); ok && us != nil {
1012
2x
            return us.GetUserByID(ctx, id)
1013
2x
        }
1014
        // No userService available or not concrete - return nil so helper falls back to UTC
1015
6x
        return nil, nil
1016
    }
1017

1018
    // Determine user-local 2-day window and pass UTC timestamps to query
1019
14x
    startUTC, endUTC, _, err := contextutils.UserLocalDayRange(ctx, userID, 2, userLookup)
1020
14x
    if err != nil {
1021
        return 0, contextutils.WrapError(err, "failed to compute user local day range")
1022
    }
1023

1024
14x
    query := `
1025
14x
        SELECT COUNT(*)
1026
14x
        FROM questions q
1027
14x
        JOIN user_questions uq ON q.id = uq.question_id
1028
14x
        WHERE uq.user_id = $1
1029
14x
          AND q.language = $2
1030
14x
          AND q.level = $3
1031
14x
          AND q.type = $4
1032
14x
          AND q.status = 'active'
1033
14x
          AND NOT EXISTS (
1034
14x
                SELECT 1 FROM user_responses ur
1035
14x
                WHERE ur.user_id = $1
1036
14x
                  AND ur.question_id = q.id
1037
14x
                  AND ur.is_correct = TRUE
1038
14x
                  AND ur.created_at >= $5 AND ur.created_at < $6
1039
14x
          )
1040
14x
    `
1041
14x

1042
14x
    // Try to get the database from the question service
1043
14x
    var db *sql.DB
1044
14x
    if qs, ok := w.questionService.(*services.QuestionService); ok {
1045
2x
        db = qs.DB()
1046
2x
    } else {
1047
6x
        // For mock services or other implementations, we can't get the DB directly
1048
6x
        // This is expected in unit tests
1049
6x
        return 0, contextutils.ErrorWithContextf("cannot get database from question service implementation")
1050
6x
    }
1051

1052
2x
    row := db.QueryRowContext(ctx, query, userID, language, level, qType, startUTC, endUTC)
1053
2x
    var count int
1054
2x
    if err := row.Scan(&count); err != nil {
1055
        return 0, err
1056
    }
1057
2x
    return count, nil
1058
}
1059

1060
1x
func (w *Worker) processUserQuestionGeneration(ctx context.Context, user *models.User) (string, bool, bool) {
1061
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "processUserQuestionGeneration",
1062
1x
        observability.AttributeUserID(user.ID),
1063
1x
        attribute.String("user.username", user.Username),
1064
1x
        attribute.String("worker.instance", w.instance),
1065
1x
    )
1066
1x
    defer observability.FinishSpan(span, nil)
1067
1x

1068
1x
    userLanguage := "italian"
1069
1x
    if user.PreferredLanguage.Valid && user.PreferredLanguage.String != "" {
1070
1x
        userLanguage = user.PreferredLanguage.String
1071
1x
        span.SetAttributes(attribute.String("user.language", userLanguage))
1072
1x
    }
1073
1x
    userLevel := "A1"
1074
1x
    if user.CurrentLevel.Valid && user.CurrentLevel.String != "" {
1075
1x
        userLevel = user.CurrentLevel.String
1076
1x
        span.SetAttributes(attribute.String("user.level", userLevel))
1077
1x
    }
1078
1x
    languages := []string{userLanguage}
1079
1x
    levels := []string{userLevel}
1080
1x
    questionTypes := []models.QuestionType{
1081
1x
        models.Vocabulary,
1082
1x
        models.FillInBlank,
1083
1x
        models.QuestionAnswer,
1084
1x
        models.ReadingComprehension,
1085
1x
    }
1086
1x

1087
1x
    // Reorder types based on active generation hints (hinted types first, stable order)
1088
1x
    if w.hintService != nil {
1089
        if hints, err := w.hintService.GetActiveHintsForUser(ctx, user.ID); err == nil && len(hints) > 0 {
1090
            hinted := make([]models.QuestionType, 0, len(hints))
1091
            hintedSet := map[models.QuestionType]bool{}
1092
            for _, h := range hints {
1093
                qt := models.QuestionType(h.QuestionType)
1094
                hinted = append(hinted, qt)
1095
                hintedSet[qt] = true
1096
            }
1097
            rest := make([]models.QuestionType, 0, len(questionTypes))
1098
            for _, qt := range questionTypes {
1099
                if !hintedSet[qt] {
1100
                    rest = append(rest, qt)
1101
                }
1102
            }
1103
            questionTypes = append(hinted, rest...)
1104
        }
1105
    }
1106
1x
    var actions []string
1107
1x
    var hadAttemptedOperations bool
1108
1x
    var hadFailures bool
1109
1x
    for _, language := range languages {
1110
1x
        for _, level := range levels {
1111
1x
            for _, qType := range questionTypes {
1112
4x
                activity := fmt.Sprintf("Checking questions for user %s: %s %s %s", user.Username, language, level, qType)
1113
4x
                w.updateActivity(activity)
1114
4x
                // Use eligible question count (not just total assigned)
1115
4x
                eligibleCount, err := w.getEligibleQuestionCount(ctx, user.ID, language, level, qType)
1116
4x
                if err != nil {
1117
4x
                    span.RecordError(err)
1118
4x
                    hadFailures = true
1119
4x
                    continue // Continue to next question type
1120
                }
1121
                // If hinted, be more aggressive about generating for that type
1122
                hinted := false
1123
                if w.hintService != nil {
1124
                    if hints, err := w.hintService.GetActiveHintsForUser(ctx, user.ID); err == nil {
1125
                        for _, h := range hints {
1126
                            if models.QuestionType(h.QuestionType) == qType {
1127
                                hinted = true
1128
                                break
1129
                            }
1130
                        }
1131
                    }
1132
                }
1133

1134
                refillThreshold := w.cfg.Server.QuestionRefillThreshold
1135
                if hinted {
1136
                    // Treat as if pool is empty to trigger generation, but keep batch sizing logic
1137
                    eligibleCount = 0
1138
                }
1139

1140
                if eligibleCount < refillThreshold {
1141
                    provider := "default"
1142
                    if user.AIProvider.Valid && user.AIProvider.String != "" {
1143
                        provider = user.AIProvider.String
1144
                    }
1145
                    // Base batch size from AI provider
1146
                    needed := w.aiService.GetQuestionBatchSize(provider)
1147

1148
                    // Get user's learning preferences to use their personal FreshQuestionRatio
1149
                    userPrefs, prefsErr := w.learningService.GetUserLearningPreferences(ctx, user.ID)
1150
                    userFreshRatio := 0.7 // default fallback
1151
                    if prefsErr == nil && userPrefs != nil && userPrefs.FreshQuestionRatio > 0 {
1152
                        userFreshRatio = userPrefs.FreshQuestionRatio
1153
                    } else if prefsErr != nil {
1154
                        w.logger.Warn(ctx, "Failed to get user learning preferences, using default fresh ratio", map[string]interface{}{
1155
                            "user_id": user.ID,
1156
                            "error":   prefsErr.Error(),
1157
                        })
1158
                    }
1159

1160
                    // Ensure at least enough fresh questions are available to meet the user's personal FreshQuestionRatio.
1161
                    // This ensures daily question assignment can respect the user's freshness preference.
1162
                    desiredFresh := int(math.Ceil(float64(refillThreshold) * userFreshRatio))
1163
                    freshCandidates := 0
1164
                    if qs, qerr := w.questionService.GetAdaptiveQuestionsForDaily(ctx, user.ID, language, level, 50); qerr == nil && qs != nil {
1165
                        for _, q := range qs {
1166
                            if q != nil && q.TotalResponses == 0 {
1167
                                freshCandidates++
1168
                            }
1169
                        }
1170
                    } else if qerr != nil {
1171
                        // Log but don't fail - we'll conservatively proceed with base batch size
1172
                        w.logger.Warn(ctx, "Failed to fetch adaptive questions for fresh-count check", map[string]interface{}{
1173
                            "user_id": user.ID,
1174
                            "error":   qerr.Error(),
1175
                        })
1176
                    }
1177

1178
                    if missing := desiredFresh - freshCandidates; missing > 0 {
1179
                        needed += missing
1180
                        w.logger.Info(ctx, "Adjusting generation batch to meet user's personal fresh-question requirement", map[string]interface{}{
1181
                            "user_id":          user.ID,
1182
                            "language":         language,
1183
                            "level":            level,
1184
                            "question_type":    qType,
1185
                            "user_fresh_ratio": userFreshRatio,
1186
                            "base_batch_size":  w.aiService.GetQuestionBatchSize(provider),
1187
                            "desired_fresh":    desiredFresh,
1188
                            "fresh_candidates": freshCandidates,
1189
                            "added_to_batch":   missing,
1190
                            "final_batch_size": needed,
1191
                        })
1192
                    }
1193
                    hadAttemptedOperations = true
1194
                    action, err := w.GenerateQuestionsForUser(ctx, user, language, level, qType, needed, "")
1195
                    if err != nil {
1196
                        hadFailures = true
1197
                        // Continue to next question type instead of breaking all loops
1198
                        continue
1199
                    }
1200
                    if action != "" {
1201
                        actions = append(actions, action)
1202
                    }
1203
                    // Clear hint on successful generation attempt for this type
1204
                    if hinted && w.hintService != nil {
1205
                        _ = w.hintService.ClearHint(ctx, user.ID, language, level, qType)
1206
                    }
1207
                }
1208
            }
1209
        }
1210
    }
1211
1x
    return strings.Join(actions, "; "), hadAttemptedOperations, hadFailures
1212
}
1213

1214
// summarizeRunActions builds the summary string for actions taken
1215
4x
func (w *Worker) summarizeRunActions(actions, checkedUsers, actuallyProcessedUsers []string, hadAttemptedOperations, hadFailures bool) string {
1216
4x
    userList := "No users with AI configuration found"
1217
4x
    if len(checkedUsers) > 0 {
1218
4x
        userList = fmt.Sprintf("Checked users: %s", strings.Join(checkedUsers, ", "))
1219
4x
    }
1220
4x
    if len(actions) == 0 {
1221
3x
        if len(actuallyProcessedUsers) == 0 {
1222
1x
            return fmt.Sprintf("No actions taken. All users in exponential backoff. %s", userList)
1223
1x
        }
1224
2x
        if hadAttemptedOperations && hadFailures && len(actions) == 0 {
1225
1x
            return fmt.Sprintf("No actions taken due to errors. %s", userList)
1226
1x
        }
1227
1x
        return fmt.Sprintf("No actions taken. All question types have sufficient questions. %s", userList)
1228
    }
1229
1x
    userList = fmt.Sprintf("Processed users: %s", strings.Join(actuallyProcessedUsers, ", "))
1230
1x

1231
1x
    // Format actions with line breaks for better readability in UI
1232
1x
    if len(actions) == 1 {
1233
1x
        return fmt.Sprintf("%s\n%s", actions[0], userList)
1234
1x
    }
1235

1236
    formattedActions := strings.Join(actions, "\n")
1237
    return fmt.Sprintf("%s\n%s", formattedActions, userList)
1238
}
1239

1240
// GenerateQuestionsForUser generates questions for a specific user with the given parameters
1241
1x
func (w *Worker) GenerateQuestionsForUser(ctx context.Context, user *models.User, language, level string, qType models.QuestionType, count int, topic string) (result0 string, err error) {
1242
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "generate_questions_for_user",
1243
1x
        observability.AttributeUserID(user.ID),
1244
1x
        attribute.String("user.username", user.Username),
1245
1x
        attribute.String("language", language),
1246
1x
        attribute.String("level", level),
1247
1x
        attribute.String("question.type", string(qType)),
1248
1x
        attribute.Int("question.count", count),
1249
1x
        attribute.String("topic", topic),
1250
1x
        attribute.String("worker.instance", w.instance),
1251
1x
    )
1252
1x
    defer observability.FinishSpan(span, &err)
1253
1x

1254
1x
    if count <= 0 {
1255
        return "No questions needed", nil
1256
    }
1257

1258
    // Gather priority data for variety selection
1259
1x
    priorityData := w.getPriorityGenerationData(ctx, user.ID, language, level, qType)
1260
1x
    var userWeakAreas []string
1261
1x
    if priorityData != nil && priorityData.FocusOnWeakAreas {
1262
        userWeakAreas = priorityData.UserWeakAreas
1263
    }
1264
1x
    var highPriorityTopics []string
1265
1x
    if priorityData != nil {
1266
1x
        highPriorityTopics = priorityData.HighPriorityTopics
1267
1x
    }
1268
1x
    var gapAnalysis map[string]int
1269
1x
    if priorityData != nil {
1270
1x
        gapAnalysis = priorityData.GapAnalysis
1271
1x
    }
1272

1273
1x
    variety := w.aiService.VarietyService().SelectVarietyElements(ctx, level, highPriorityTopics, userWeakAreas, gapAnalysis)
1274
1x

1275
1x
    // Log priority generation decisions
1276
1x
    generationReasoning := w.getGenerationReasoning(priorityData, variety)
1277
1x

1278
1x
    var freshQuestionRatio float64
1279
1x
    if priorityData != nil {
1280
1x
        freshQuestionRatio = priorityData.FreshQuestionRatio
1281
1x
    }
1282

1283
1x
    priorityLog := PriorityGenerationLog{
1284
1x
        UserID:              user.ID,
1285
1x
        Username:            user.Username,
1286
1x
        Language:            language,
1287
1x
        Level:               level,
1288
1x
        QuestionType:        string(qType),
1289
1x
        FocusOnWeakAreas:    priorityData != nil && priorityData.FocusOnWeakAreas,
1290
1x
        UserWeakAreas:       userWeakAreas,
1291
1x
        HighPriorityTopics:  highPriorityTopics,
1292
1x
        GapAnalysis:         gapAnalysis,
1293
1x
        FreshQuestionRatio:  freshQuestionRatio,
1294
1x
        SelectedVariety:     variety,
1295
1x
        GenerationReasoning: generationReasoning,
1296
1x
        Timestamp:           time.Now(),
1297
1x
    }
1298
1x
    w.logPriorityGeneration(ctx, priorityLog)
1299
1x

1300
1x
    aiReq, recentQuestions, err := w.buildAIQuestionGenRequest(ctx, user, language, level, qType, count, topic)
1301
1x
    if err != nil {
1302
        w.logger.Warn(ctx, "Worker failed to get recent questions", map[string]interface{}{
1303
            "instance": w.instance,
1304
            "error":    err.Error(),
1305
        })
1306
        recentQuestions = []string{}
1307
    }
1308
1x
    aiReq.RecentQuestionHistory = recentQuestions
1309
1x

1310
1x
    userConfig := w.getUserAIConfig(ctx, user)
1311
1x

1312
1x
    batchLogMsg := formatBatchLogMessage(user.Username, count, string(qType), language, level, variety, userConfig.Provider, userConfig.Model)
1313
1x
    w.logger.Info(ctx, batchLogMsg, map[string]interface{}{
1314
1x
        "instance": w.instance,
1315
1x
    })
1316
1x
    w.updateActivity(batchLogMsg)
1317
1x
    w.logActivity(ctx, "INFO", batchLogMsg, &user.ID, &user.Username)
1318
1x

1319
1x
    progressMsg, questions, errAI := w.handleAIQuestionStream(ctx, userConfig, aiReq, variety, count, language, level, qType, topic, user)
1320
1x

1321
1x
    if errAI != nil {
1322
        w.recordUserFailure(ctx, user.ID, user.Username)
1323
        return progressMsg, errAI
1324
    }
1325
1x
    if len(questions) == 0 {
1326
        w.recordUserFailure(ctx, user.ID, user.Username)
1327
        return progressMsg, contextutils.WrapErrorf(contextutils.ErrAIResponseInvalid, "AI service returned 0 questions for %s %s %s", language, level, qType)
1328
    }
1329

1330
1x
    savedCount := w.saveGeneratedQuestions(ctx, user, questions, language, level, qType, topic, variety)
1331
1x

1332
1x
    if savedCount > 0 {
1333
1x
        w.recordUserSuccess(ctx, user.ID, user.Username)
1334
1x
    }
1335
1x
    if savedCount != len(questions) {
1336
        w.recordUserFailure(ctx, user.ID, user.Username)
1337
        return fmt.Sprintf("Generated %d/%d %s questions for %s %s", savedCount, len(questions), qType, language, level),
1338
            contextutils.WrapErrorf(contextutils.ErrDatabaseQuery, "only saved %d out of %d generated questions", savedCount, len(questions))
1339
    }
1340
1x
    return fmt.Sprintf("Generated %d %s questions for %s %s", savedCount, qType, language, level), nil
1341
}
1342

1343
// buildAIQuestionGenRequest prepares the AI request and gets recent questions
1344
5x
func (w *Worker) buildAIQuestionGenRequest(ctx context.Context, user *models.User, language, level string, qType models.QuestionType, count int, _ string) (result0 *models.AIQuestionGenRequest, result1 []string, err error) {
1345
5x
    ctx, span := observability.TraceWorkerFunction(ctx, "build_ai_question_gen_request",
1346
5x
        observability.AttributeUserID(user.ID),
1347
5x
        attribute.String("user.username", user.Username),
1348
5x
        attribute.String("language", language),
1349
5x
        attribute.String("level", level),
1350
5x
        attribute.String("question.type", string(qType)),
1351
5x
        attribute.Int("question.count", count),
1352
5x
        attribute.String("worker.instance", w.instance),
1353
5x
    )
1354
5x
    defer observability.FinishSpan(span, &err)
1355
5x

1356
5x
    recentQuestions, err := w.questionService.GetRecentQuestionContentsForUser(ctx, user.ID, 10)
1357
5x
    if err != nil {
1358
        span.RecordError(err)
1359
        return nil, nil, err
1360
    }
1361
5x
    aiReq := &models.AIQuestionGenRequest{
1362
5x
        Language:     language,
1363
5x
        Level:        level,
1364
5x
        QuestionType: qType,
1365
5x
        Count:        count,
1366
5x
    }
1367
5x

1368
5x
    aiReq.RecentQuestionHistory = recentQuestions
1369
5x

1370
5x
    return aiReq, recentQuestions, nil
1371
}
1372

1373
// getUserAIConfig builds the UserAIConfig struct with API key
1374
3x
func (w *Worker) getUserAIConfig(ctx context.Context, user *models.User) *services.UserAIConfig {
1375
3x
    ctx, span := observability.TraceWorkerFunction(ctx, "get_user_ai_config",
1376
3x
        observability.AttributeUserID(user.ID),
1377
3x
        attribute.String("user.username", user.Username),
1378
3x
        attribute.String("worker.instance", w.instance),
1379
3x
    )
1380
3x
    defer observability.FinishSpan(span, nil)
1381
3x

1382
3x
    provider := ""
1383
3x
    if user.AIProvider.Valid {
1384
3x
        provider = user.AIProvider.String
1385
3x
        span.SetAttributes(attribute.String("ai.provider", provider))
1386
3x
    }
1387
3x
    model := ""
1388
3x
    if user.AIModel.Valid {
1389
3x
        model = user.AIModel.String
1390
3x
        span.SetAttributes(attribute.String("ai.model", model))
1391
3x
    }
1392
3x
    apiKey := ""
1393
3x
    if provider != "" {
1394
3x
        savedKey, err := w.userService.GetUserAPIKey(ctx, user.ID, provider)
1395
3x
        if err == nil && savedKey != "" {
1396
1x
            apiKey = savedKey
1397
1x
        }
1398
    }
1399
3x
    return &services.UserAIConfig{
1400
3x
        Provider: provider,
1401
3x
        Model:    model,
1402
3x
        APIKey:   apiKey,
1403
3x
        Username: user.Username,
1404
3x
    }
1405
}
1406

1407
// handleAIQuestionStream handles the AI streaming and collects questions
1408
1x
func (w *Worker) handleAIQuestionStream(ctx context.Context, userConfig *services.UserAIConfig, req *models.AIQuestionGenRequest, variety *services.VarietyElements, count int, language, level string, qType models.QuestionType, topic string, user *models.User) (result0 string, result1 []*models.Question, err error) {
1409
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "handle_ai_question_stream",
1410
1x
        attribute.String("ai.provider", userConfig.Provider),
1411
1x
        attribute.String("ai.model", userConfig.Model),
1412
1x
        attribute.String("language", language),
1413
1x
        attribute.String("level", level),
1414
1x
        attribute.String("question.type", string(qType)),
1415
1x
        attribute.Int("question.count", count),
1416
1x
        attribute.String("topic", topic),
1417
1x
        attribute.String("user.username", user.Username),
1418
1x
        attribute.String("worker.instance", w.instance),
1419
1x
    )
1420
1x
    defer observability.FinishSpan(span, &err)
1421
1x

1422
1x
    progressChan := make(chan *models.Question)
1423
1x
    var questions []*models.Question
1424
1x
    var wg sync.WaitGroup
1425
1x
    var errAI error
1426
1x
    progressMsg := ""
1427
1x
    wg.Add(1)
1428
1x
    go func() {
1429
1x
        defer func() {
1430
1x
            if r := recover(); r != nil {
1431
                w.logger.Error(ctx, "Panic in AI question stream goroutine", nil, map[string]interface{}{
1432
                    "instance": w.instance,
1433
                    "panic":    fmt.Sprintf("%v", r),
1434
                })
1435
            }
1436
1x
            wg.Done()
1437
        }()
1438
1x
        errAI = w.aiService.GenerateQuestionsStream(ctx, userConfig, req, progressChan, variety)
1439
    }()
1440
1x
    generatedCount := 0
1441
1x
    for question := range progressChan {
1442
1x
        generatedCount++
1443
1x
        progressMsg = fmt.Sprintf("Generated %d/%d %s questions for %s %s", generatedCount, count, qType, language, level)
1444
1x
        if topic != "" {
1445
1x
            progressMsg = fmt.Sprintf("Generated %d/%d %s questions for %s %s (topic: %s)", generatedCount, count, qType, language, level, topic)
1446
1x
        }
1447
1x
        w.logger.Info(ctx, progressMsg, map[string]interface{}{
1448
1x
            "instance": w.instance,
1449
1x
        })
1450
1x
        w.updateActivity(progressMsg)
1451
1x
        w.logActivity(ctx, "INFO", progressMsg, &user.ID, &user.Username)
1452
1x
        questions = append(questions, question)
1453
    }
1454
1x
    wg.Wait()
1455
1x
    return progressMsg, questions, errAI
1456
}
1457

1458
// saveGeneratedQuestions saves questions to the DB and returns the count
1459
7x
func (w *Worker) saveGeneratedQuestions(ctx context.Context, user *models.User, questions []*models.Question, language, level string, qType models.QuestionType, topic string, variety *services.VarietyElements) int {
1460
7x
    ctx, span := observability.TraceWorkerFunction(ctx, "save_generated_questions",
1461
7x
        observability.AttributeUserID(user.ID),
1462
7x
        attribute.String("user.username", user.Username),
1463
7x
        attribute.String("language", language),
1464
7x
        attribute.String("level", level),
1465
7x
        attribute.String("question.type", string(qType)),
1466
7x
        attribute.Int("question.count", len(questions)),
1467
7x
        attribute.String("topic", topic),
1468
7x
        attribute.String("worker.instance", w.instance),
1469
7x
    )
1470
7x
    defer observability.FinishSpan(span, nil)
1471
7x

1472
7x
    savingMsg := fmt.Sprintf("Saving %d new %s questions for %s %s", len(questions), qType, language, level)
1473
7x
    if topic != "" {
1474
3x
        savingMsg = fmt.Sprintf("Saving %d new %s questions for %s %s (topic: %s)", len(questions), qType, language, level, topic)
1475
3x
    }
1476
7x
    w.logger.Info(ctx, savingMsg, map[string]interface{}{
1477
7x
        "instance": w.instance,
1478
7x
    })
1479
7x
    w.updateActivity(savingMsg)
1480
7x
    w.logActivity(ctx, "INFO", savingMsg, &user.ID, &user.Username)
1481
7x
    savedCount := 0
1482
7x
    for _, q := range questions {
1483
9x
        // Populate variety fields from the variety elements used during generation
1484
9x
        if variety != nil {
1485
7x
            q.TopicCategory = variety.TopicCategory
1486
7x
            q.GrammarFocus = variety.GrammarFocus
1487
7x
            q.VocabularyDomain = variety.VocabularyDomain
1488
7x
            q.Scenario = variety.Scenario
1489
7x
            q.StyleModifier = variety.StyleModifier
1490
7x
            q.DifficultyModifier = variety.DifficultyModifier
1491
7x
            q.TimeContext = variety.TimeContext
1492
7x
        }
1493
9x
        if err := w.questionService.SaveQuestion(ctx, q); err != nil {
1494
            w.logger.Error(ctx, "Failed to save generated question", err, map[string]interface{}{
1495
                "instance":      w.instance,
1496
                "user_id":       user.ID,
1497
                "language":      language,
1498
                "level":         level,
1499
                "question_type": qType,
1500
            })
1501
        } else {
1502
9x
            // Assign the question to the user after saving
1503
9x
            if err := w.questionService.AssignQuestionToUser(ctx, q.ID, user.ID); err != nil {
1504
                w.logger.Error(ctx, "Failed to assign question to user", err, map[string]interface{}{
1505
                    "instance":    w.instance,
1506
                    "question_id": q.ID,
1507
                    "user_id":     user.ID,
1508
                })
1509
            } else {
1510
9x
                savedCount++
1511
9x
            }
1512
        }
1513
    }
1514
7x
    if savedCount > 0 {
1515
7x
        successMsg := fmt.Sprintf("Successfully saved %d new '%s' questions for %s %s", savedCount, qType, language, level)
1516
7x
        if topic != "" {
1517
3x
            successMsg = fmt.Sprintf("Successfully saved %d new '%s' questions for %s %s (topic: %s)", savedCount, qType, language, level, topic)
1518
3x
        }
1519
7x
        w.logActivity(ctx, "INFO", successMsg, &user.ID, &user.Username)
1520
    }
1521
7x
    return savedCount
1522
}
1523

1524
20x
func (w *Worker) updateActivity(activity string) {
1525
20x
    w.mu.Lock()
1526
20x
    defer w.mu.Unlock()
1527
20x
    w.status.CurrentActivity = activity
1528
20x
}
1529

1530
// logActivity adds an activity log entry
1531
247x
func (w *Worker) logActivity(_ context.Context, _, message string, userID *int, username *string) {
1532
247x
    w.mu.Lock()
1533
247x
    defer w.mu.Unlock()
1534
247x

1535
247x
    logEntry := ActivityLog{
1536
247x
        Timestamp: time.Now(),
1537
247x
        Level:     "INFO",
1538
247x
        Message:   message,
1539
247x
        UserID:    userID,
1540
247x
        Username:  username,
1541
247x
    }
1542
247x

1543
247x
    // Add to activity logs (circular buffer)
1544
247x
    w.activityLogs = append(w.activityLogs, logEntry)
1545
247x

1546
247x
    // Keep only the last maxActivityLogs entries
1547
247x
    if len(w.activityLogs) > w.cfg.Server.MaxActivityLogs {
1548
10x
        w.activityLogs = w.activityLogs[len(w.activityLogs)-w.cfg.Server.MaxActivityLogs:]
1549
10x
    }
1550
}
1551

1552
// shouldRetryUser checks if enough time has passed since the last failure for exponential backoff
1553
7x
func (w *Worker) shouldRetryUser(userID int) bool {
1554
7x
    w.failureMu.RLock()
1555
7x
    defer w.failureMu.RUnlock()
1556
7x

1557
7x
    failure, exists := w.userFailures[userID]
1558
7x
    if !exists {
1559
4x
        return true // No previous failures, go ahead
1560
4x
    }
1561

1562
3x
    return time.Now().After(failure.NextRetryTime)
1563
}
1564

1565
// recordUserFailure records a failure and calculates the next retry time with exponential backoff
1566
11x
func (w *Worker) recordUserFailure(ctx context.Context, userID int, username string) {
1567
11x
    ctx, span := observability.TraceWorkerFunction(ctx, "record_user_failure",
1568
11x
        observability.AttributeUserID(userID),
1569
11x
        attribute.String("user.username", username),
1570
11x
        attribute.String("worker.instance", w.instance),
1571
11x
    )
1572
11x
    defer observability.FinishSpan(span, nil)
1573
11x

1574
11x
    w.failureMu.Lock()
1575
11x
    defer w.failureMu.Unlock()
1576
11x

1577
11x
    failure, exists := w.userFailures[userID]
1578
11x
    if !exists {
1579
7x
        failure = &UserFailureInfo{}
1580
7x
        w.userFailures[userID] = failure
1581
7x
    }
1582

1583
11x
    failure.ConsecutiveFailures++
1584
11x
    failure.LastFailureTime = time.Now()
1585
11x

1586
11x
    // Exponential backoff: 2^failures seconds, max 1 hour
1587
11x
    backoffSeconds := int(math.Pow(2, float64(failure.ConsecutiveFailures)))
1588
11x
    if backoffSeconds > 3600 {
1589
        backoffSeconds = 3600
1590
    }
1591
11x
    failure.NextRetryTime = time.Now().Add(time.Duration(backoffSeconds) * time.Second)
1592
11x

1593
11x
    span.SetAttributes(
1594
11x
        attribute.Int("failure.count", failure.ConsecutiveFailures),
1595
11x
        attribute.Int("backoff.seconds", backoffSeconds),
1596
11x
    )
1597
11x

1598
11x
    w.logger.Info(ctx, "Worker recorded user failure", map[string]interface{}{
1599
11x
        "instance":           w.instance,
1600
11x
        "username":           username,
1601
11x
        "failure_count":      failure.ConsecutiveFailures,
1602
11x
        "next_retry_seconds": backoffSeconds,
1603
11x
    })
1604
}
1605

1606
// recordUserSuccess clears the failure count for a user
1607
5x
func (w *Worker) recordUserSuccess(ctx context.Context, userID int, username string) {
1608
5x
    ctx, span := observability.TraceWorkerFunction(ctx, "record_user_success",
1609
5x
        observability.AttributeUserID(userID),
1610
5x
        attribute.String("user.username", username),
1611
5x
        attribute.String("worker.instance", w.instance),
1612
5x
    )
1613
5x
    defer observability.FinishSpan(span, nil)
1614
5x

1615
5x
    w.failureMu.Lock()
1616
5x
    defer w.failureMu.Unlock()
1617
5x

1618
5x
    failure, exists := w.userFailures[userID]
1619
5x
    if exists && failure.ConsecutiveFailures > 0 {
1620
1x
        span.SetAttributes(attribute.Int("previous_failures", failure.ConsecutiveFailures))
1621
1x
        w.logger.Info(ctx, "Worker user success after failures, resetting backoff", map[string]interface{}{
1622
1x
            "instance":          w.instance,
1623
1x
            "username":          username,
1624
1x
            "previous_failures": failure.ConsecutiveFailures,
1625
1x
        })
1626
1x
        delete(w.userFailures, userID)
1627
1x
    }
1628
}
1629

1630
// formatBatchLogMessage creates a formatted log message for batch question generation
1631
7x
func formatBatchLogMessage(username string, count int, qType, language, level string, variety *services.VarietyElements, provider, model string) string {
1632
7x
    var summaryFields []string
1633
7x
    if variety != nil {
1634
5x
        if variety.GrammarFocus != "" {
1635
2x
            summaryFields = append(summaryFields, "grammar: "+variety.GrammarFocus)
1636
2x
        }
1637
5x
        if variety.TopicCategory != "" {
1638
5x
            summaryFields = append(summaryFields, "topic: "+variety.TopicCategory)
1639
5x
        }
1640
5x
        if variety.Scenario != "" {
1641
1x
            summaryFields = append(summaryFields, "scenario: "+variety.Scenario)
1642
1x
        }
1643
5x
        if variety.StyleModifier != "" {
1644
3x
            summaryFields = append(summaryFields, "style: "+variety.StyleModifier)
1645
3x
        }
1646
5x
        if variety.DifficultyModifier != "" {
1647
3x
            summaryFields = append(summaryFields, "difficulty: "+variety.DifficultyModifier)
1648
3x
        }
1649
5x
        if variety.VocabularyDomain != "" {
1650
1x
            summaryFields = append(summaryFields, "vocab: "+variety.VocabularyDomain)
1651
1x
        }
1652
5x
        if variety.TimeContext != "" {
1653
1x
            summaryFields = append(summaryFields, "time: "+variety.TimeContext)
1654
1x
        }
1655
    }
1656
7x
    providerModel := "provider: " + provider + ", model: " + model
1657
7x
    if len(summaryFields) > 0 {
1658
5x
        summaryFields = append(summaryFields, providerModel)
1659
5x
    } else {
1660
1x
        summaryFields = []string{providerModel}
1661
1x
    }
1662
7x
    return fmt.Sprintf("Worker [user=%s]: Batch %d %s questions (lang: %s, level: %s) | %s", username, count, qType, language, level, strings.Join(summaryFields, " | "))
1663
}
1664

1665
// PriorityGenerationData contains priority information to guide AI question generation
1666
type PriorityGenerationData struct {
1667
    UserWeakAreas        []string                        `json:"user_weak_areas,omitempty"`
1668
    HighPriorityTopics   []string                        `json:"high_priority_topics,omitempty"`
1669
    GapAnalysis          map[string]int                  `json:"gap_analysis,omitempty"`
1670
    UserPreferences      *models.UserLearningPreferences `json:"user_preferences,omitempty"`
1671
    PriorityDistribution map[string]int                  `json:"priority_distribution,omitempty"`
1672
    FocusOnWeakAreas     bool                            `json:"focus_on_weak_areas"`
1673
    FreshQuestionRatio   float64                         `json:"fresh_question_ratio"`
1674
}
1675

1676
// PriorityGenerationLog contains structured data about priority-aware generation decisions
1677
type PriorityGenerationLog struct {
1678
    UserID              int                       `json:"user_id"`
1679
    Username            string                    `json:"username"`
1680
    Language            string                    `json:"language"`
1681
    Level               string                    `json:"level"`
1682
    QuestionType        string                    `json:"question_type"`
1683
    FocusOnWeakAreas    bool                      `json:"focus_on_weak_areas"`
1684
    UserWeakAreas       []string                  `json:"user_weak_areas,omitempty"`
1685
    HighPriorityTopics  []string                  `json:"high_priority_topics,omitempty"`
1686
    GapAnalysis         map[string]int            `json:"gap_analysis,omitempty"`
1687
    FreshQuestionRatio  float64                   `json:"fresh_question_ratio"`
1688
    SelectedVariety     *services.VarietyElements `json:"selected_variety"`
1689
    GenerationReasoning string                    `json:"generation_reasoning"`
1690
    Timestamp           time.Time                 `json:"timestamp"`
1691
}
1692

1693
// logPriorityGeneration logs priority generation data as JSON
1694
1x
func (w *Worker) logPriorityGeneration(ctx context.Context, priorityLog PriorityGenerationLog) {
1695
1x
    ctx, span := observability.TraceWorkerFunction(ctx, "log_priority_generation",
1696
1x
        observability.AttributeUserID(priorityLog.UserID),
1697
1x
        attribute.String("user.username", priorityLog.Username),
1698
1x
        attribute.String("language", priorityLog.Language),
1699
1x
        attribute.String("level", priorityLog.Level),
1700
1x
        attribute.String("question.type", priorityLog.QuestionType),
1701
1x
        attribute.String("worker.instance", w.instance),
1702
1x
    )
1703
1x
    defer observability.FinishSpan(span, nil)
1704
1x

1705
1x
    logJSON, err := json.Marshal(priorityLog)
1706
1x
    if err != nil {
1707
        span.RecordError(err)
1708
        w.logger.Error(ctx, "Failed to marshal priority generation log", err, map[string]interface{}{
1709
            "instance": w.instance,
1710
        })
1711
        return
1712
    }
1713
1x
    w.logger.Info(ctx, "Worker priority generation", map[string]interface{}{
1714
1x
        "instance": w.instance,
1715
1x
        "data":     string(logJSON),
1716
1x
    })
1717
}
1718

1719
// getGenerationReasoning provides a human-readable explanation of the generation strategy
1720
17x
func (w *Worker) getGenerationReasoning(priorityData *PriorityGenerationData, variety *services.VarietyElements) string {
1721
17x
    if priorityData == nil {
1722
1x
        return "standard generation"
1723
1x
    }
1724
15x
    var reasons []string
1725
15x

1726
15x
    if priorityData.FocusOnWeakAreas && len(priorityData.UserWeakAreas) > 0 {
1727
2x
        reasons = append(reasons, fmt.Sprintf("focusing on weak areas: %s", strings.Join(priorityData.UserWeakAreas, ", ")))
1728
2x
    }
1729

1730
15x
    if len(priorityData.HighPriorityTopics) > 0 {
1731
2x
        reasons = append(reasons, fmt.Sprintf("high priority topics: %s", strings.Join(priorityData.HighPriorityTopics, ", ")))
1732
2x
    }
1733

1734
15x
    if len(priorityData.GapAnalysis) > 0 {
1735
2x
        var gaps []string
1736
2x
        for topic, count := range priorityData.GapAnalysis {
1737
2x
            gaps = append(gaps, fmt.Sprintf("%s(%d)", topic, count))
1738
2x
        }
1739
2x
        reasons = append(reasons, fmt.Sprintf("gap analysis: %s", strings.Join(gaps, ", ")))
1740
    }
1741

1742
15x
    if priorityData.FreshQuestionRatio > 0 {
1743
9x
        reasons = append(reasons, fmt.Sprintf("fresh ratio: %.1f%%", priorityData.FreshQuestionRatio*100))
1744
9x
    }
1745

1746
15x
    if variety != nil {
1747
1x
        var varietyElements []string
1748
1x
        if variety.TopicCategory != "" {
1749
1x
            varietyElements = append(varietyElements, fmt.Sprintf("topic:%s", variety.TopicCategory))
1750
1x
        }
1751
1x
        if variety.GrammarFocus != "" {
1752
            varietyElements = append(varietyElements, fmt.Sprintf("grammar:%s", variety.GrammarFocus))
1753
        }
1754
1x
        if variety.VocabularyDomain != "" {
1755
            varietyElements = append(varietyElements, fmt.Sprintf("vocab:%s", variety.VocabularyDomain))
1756
        }
1757
1x
        if variety.Scenario != "" {
1758
            varietyElements = append(varietyElements, fmt.Sprintf("scenario:%s", variety.Scenario))
1759
        }
1760
1x
        if len(varietyElements) > 0 {
1761
1x
            reasons = append(reasons, fmt.Sprintf("variety: %s", strings.Join(varietyElements, ", ")))
1762
1x
        }
1763
    }
1764

1765
15x
    if len(reasons) == 0 {
1766
        return "standard generation"
1767
    }
1768

1769
15x
    return strings.Join(reasons, "; ")
1770
}
1771

1772
// getPriorityGenerationData gathers priority data for AI question generation
1773
1x
func (w *Worker) getPriorityGenerationData(ctx context.Context, userID int, language, level string, questionType models.QuestionType) *PriorityGenerationData {
1774
1x
    // Get user preferences
1775
1x
    prefs, err := w.learningService.GetUserLearningPreferences(ctx, userID)
1776
1x
    if err != nil {
1777
        w.logger.Warn(ctx, "Worker failed to get user preferences", map[string]interface{}{
1778
            "instance": w.instance,
1779
            "user_id":  userID,
1780
            "error":    err.Error(),
1781
        })
1782
        prefs = w.getDefaultLearningPreferences()
1783
    }
1784

1785
    // Get weak areas
1786
1x
    weakAreas, err := w.learningService.GetUserWeakAreas(ctx, userID, 5)
1787
1x
    if err != nil {
1788
        w.logger.Warn(ctx, "Worker failed to get weak areas", map[string]interface{}{
1789
            "instance": w.instance,
1790
            "user_id":  userID,
1791
            "error":    err.Error(),
1792
        })
1793
        weakAreas = []map[string]interface{}{}
1794
    }
1795

1796
    // Convert weak areas to topic strings
1797
1x
    var weakAreaTopics []string
1798
1x
    for _, area := range weakAreas {
1799
        if topic, ok := area["topic"].(string); ok && topic != "" {
1800
            weakAreaTopics = append(weakAreaTopics, topic)
1801
        }
1802
    }
1803

1804
    // Get high priority topics
1805
1x
    highPriorityTopics, err := w.getHighPriorityTopics(ctx, userID, language, level, questionType)
1806
1x
    if err != nil {
1807
        w.logger.Warn(ctx, "Worker failed to get high priority topics", map[string]interface{}{
1808
            "instance": w.instance,
1809
            "user_id":  userID,
1810
            "error":    err.Error(),
1811
        })
1812
        highPriorityTopics = []string{}
1813
    }
1814

1815
    // Get gap analysis
1816
1x
    gapAnalysis, err := w.getGapAnalysis(ctx, userID, language, level, questionType)
1817
1x
    if err != nil {
1818
        w.logger.Warn(ctx, "Worker failed to get gap analysis", map[string]interface{}{
1819
            "instance": w.instance,
1820
            "user_id":  userID,
1821
            "error":    err.Error(),
1822
        })
1823
        gapAnalysis = map[string]int{}
1824
    }
1825

1826
    // Get priority distribution
1827
1x
    priorityDistribution, err := w.getPriorityDistribution(ctx, userID, language, level, questionType)
1828
1x
    if err != nil {
1829
        w.logger.Warn(ctx, "Worker failed to get priority distribution", map[string]interface{}{
1830
            "instance": w.instance,
1831
            "user_id":  userID,
1832
            "error":    err.Error(),
1833
        })
1834
        priorityDistribution = map[string]int{}
1835
    }
1836

1837
    // Determine if we should focus on weak areas
1838
1x
    focusOnWeakAreas := len(weakAreaTopics) > 0 && prefs != nil && prefs.FocusOnWeakAreas
1839
1x

1840
1x
    return &PriorityGenerationData{
1841
1x
        UserWeakAreas:        weakAreaTopics,
1842
1x
        HighPriorityTopics:   highPriorityTopics,
1843
1x
        GapAnalysis:          gapAnalysis,
1844
1x
        UserPreferences:      prefs,
1845
1x
        PriorityDistribution: priorityDistribution,
1846
1x
        FocusOnWeakAreas:     focusOnWeakAreas,
1847
1x
        FreshQuestionRatio:   prefs.FreshQuestionRatio,
1848
1x
    }
1849
}
1850

1851
// getDefaultLearningPreferences returns default learning preferences
1852
func (w *Worker) getDefaultLearningPreferences() *models.UserLearningPreferences {
1853
    return &models.UserLearningPreferences{
1854
        FocusOnWeakAreas:   false,
1855
        FreshQuestionRatio: 0.3,
1856
        WeakAreaBoost:      1.5,
1857
    }
1858
}
1859

1860
// getHighPriorityTopics returns topics that have high average priority scores
1861
20x
func (w *Worker) getHighPriorityTopics(ctx context.Context, userID int, language, level string, questionType models.QuestionType) (result0 []string, err error) {
1862
20x
    return w.workerService.GetHighPriorityTopics(ctx, userID, language, level, string(questionType))
1863
20x
}
1864

1865
// getGapAnalysis identifies areas with insufficient questions available
1866
23x
func (w *Worker) getGapAnalysis(ctx context.Context, userID int, language, level string, questionType models.QuestionType) (result0 map[string]int, err error) {
1867
23x
    return w.workerService.GetGapAnalysis(ctx, userID, language, level, string(questionType))
1868
23x
}
1869

1870
// getPriorityDistribution returns the distribution of priority scores
1871
20x
func (w *Worker) getPriorityDistribution(ctx context.Context, userID int, language, level string, questionType models.QuestionType) (result0 map[string]int, err error) {
1872
20x
    return w.workerService.GetPriorityDistribution(ctx, userID, language, level, string(questionType))
1873
20x
}
1874